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

@@ -52,6 +52,14 @@ pub enum Action {
CopyEntry,
GitPull,
GitPush,
OtpCode,
OtpCopyCode,
OtpUri,
OtpCopyUri,
OtpQr,
OtpInsert,
OtpAppend,
OtpValidate,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -567,6 +575,110 @@ pub static ACTIONS: &[ActionSpec] = &[
)),
modes: BROWSER_LIKE,
},
ActionSpec {
action: Action::OtpCode,
label: "generate OTP code",
command: "otp-code",
bindings: sequences!((
KeyCode::Char('o'),
KeyModifiers::NONE,
KeyCode::Char('c'),
KeyModifiers::NONE,
"o c"
)),
modes: &[Mode::Viewer],
},
ActionSpec {
action: Action::OtpCopyCode,
label: "copy OTP code",
command: "otp-copy-code",
bindings: sequences!((
KeyCode::Char('o'),
KeyModifiers::NONE,
KeyCode::Char('y'),
KeyModifiers::NONE,
"o y"
)),
modes: &[Mode::Viewer],
},
ActionSpec {
action: Action::OtpUri,
label: "show OTP URI",
command: "otp-uri",
bindings: sequences!((
KeyCode::Char('o'),
KeyModifiers::NONE,
KeyCode::Char('u'),
KeyModifiers::NONE,
"o u"
)),
modes: &[Mode::Viewer],
},
ActionSpec {
action: Action::OtpCopyUri,
label: "copy OTP URI",
command: "otp-copy-uri",
bindings: sequences!((
KeyCode::Char('o'),
KeyModifiers::NONE,
KeyCode::Char('x'),
KeyModifiers::NONE,
"o x"
)),
modes: &[Mode::Viewer],
},
ActionSpec {
action: Action::OtpQr,
label: "show OTP QR",
command: "otp-qr",
bindings: sequences!((
KeyCode::Char('o'),
KeyModifiers::NONE,
KeyCode::Char('q'),
KeyModifiers::NONE,
"o q"
)),
modes: &[Mode::Viewer],
},
ActionSpec {
action: Action::OtpInsert,
label: "insert OTP URI",
command: "otp-insert",
bindings: sequences!((
KeyCode::Char('o'),
KeyModifiers::NONE,
KeyCode::Char('i'),
KeyModifiers::NONE,
"o i"
)),
modes: &[Mode::Browser],
},
ActionSpec {
action: Action::OtpAppend,
label: "append OTP URI",
command: "otp-append",
bindings: sequences!((
KeyCode::Char('o'),
KeyModifiers::NONE,
KeyCode::Char('a'),
KeyModifiers::NONE,
"o a"
)),
modes: ENTRY_CONTEXT,
},
ActionSpec {
action: Action::OtpValidate,
label: "validate OTP URI",
command: "otp-validate",
bindings: sequences!((
KeyCode::Char('o'),
KeyModifiers::NONE,
KeyCode::Char('v'),
KeyModifiers::NONE,
"o v"
)),
modes: BROWSER_LIKE,
},
];
pub fn resolve_key(mode: Mode, code: KeyCode, modifiers: KeyModifiers) -> Option<Action> {

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"));
}
}

View File

@@ -46,6 +46,12 @@ impl ClipboardCancellations {
fn register(&mut self, cancellation: Sender<()>) {
self.0.push(cancellation);
}
fn cancel_all(&mut self) {
for cancellation in self.0.drain(..) {
let _ignored = cancellation.send(());
}
}
}
impl Drop for ClipboardCancellations {
@@ -75,6 +81,16 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
for result in executor.drain() {
app.apply_result(result);
}
if let Some(value) = app.take_clipboard_request() {
apply_app_effect(
&mut app,
AppEffect::CopyFocused(value),
&mut authentication,
&executor,
&mut git_control,
&mut clipboard_cancellations,
);
}
if !app.git_pending() {
git_control = None;
}
@@ -98,6 +114,34 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
terminal.draw(|frame| ui::draw(frame, &app))?;
if !event::poll(TICK_INTERVAL)? {
app.tick();
if let Some((entry, field)) = app.begin_totp_refresh() {
if let (Some(handle), Some(config)) = (
authentication
.as_ref()
.and_then(AuthenticationCoordinator::handle),
app.config().cloned(),
) {
let token = app.begin_request();
executor.submit(token, move || {
execute_otp_ui(
&config,
crate::app::OtpUiRequest {
request: ironstorage::command::OtpRequest::Code(
ironstorage::command::OtpCodeRequest {
entry,
clipboard: false,
},
),
confirmed_hotp: false,
field: Some(field),
},
handle,
)
});
} else {
app.authentication_failed("authentication lease expired".to_owned());
}
}
if let Some(coordinator) = authentication.as_mut() {
if let Some(event) = coordinator.poll_lease() {
apply_authentication_event(
@@ -246,6 +290,15 @@ fn apply_app_effect(
);
}
}
AppEffect::AuthenticateOtp(request) => {
if let Some(coordinator) = authentication.as_mut() {
coordinator.request_otp(request);
} else {
app.authentication_failed(
"operating-system secure storage is unavailable".to_owned(),
);
}
}
AppEffect::CancelGit => {
if let Some(control) = git_control.as_ref() {
control.cancel();
@@ -264,6 +317,10 @@ fn apply_app_effect(
}
AppEffect::CopyFocused(value) => {
if let Some(config) = app.config().cloned() {
app.report_status(format!(
"Secret copied; cleanup in {}s",
config.clipboard_timeout().duration().as_secs()
));
let (cancel, cancellation) = mpsc::channel();
clipboard_cancellations.register(cancel);
let token = app.begin_request();
@@ -306,6 +363,7 @@ fn apply_app_effect(
executor.submit(token, move || Ok(save_document(&config, entry, editor)));
}
AppEffect::ManualLock => {
clipboard_cancellations.cancel_all();
if let Some(coordinator) = authentication.as_mut()
&& let Err(error) = coordinator.lock()
{
@@ -352,6 +410,16 @@ fn apply_app_effect(
executor.submit(token, move || execute_git(&config, request, None, &control));
}
}
AppEffect::RunCommand(ironstorage::command::CommandRequest::Otp(
ironstorage::command::OtpRequest::Validate { uri },
)) => {
let token = app.begin_request();
executor.submit(token, move || {
ironstorage::otp::OtpService::validate(&uri)
.map(|()| AsyncPayload::OtpValidated)
.map_err(|error| error.to_string())
});
}
AppEffect::RunCommand(request) => {
app.report_status(format!(
"{} is not implemented by this terminal workflow yet",
@@ -683,6 +751,78 @@ fn execute_git_resolution(
})
}
fn execute_otp_ui(
config: &ironstorage::config::Config,
request: crate::app::OtpUiRequest,
mut provider: ironstorage::authentication::NativeAuthenticationHandle,
) -> Result<AsyncPayload, String> {
use ironstorage::{
command::{OtpRequest, OtpUriPresentation},
otp::{OtpKind, OtpService},
presentation::QrMatrix,
};
let repository = ironstorage::repository::Repository::open(config.vault())
.map_err(|error| error.to_string())?;
let keys = ironstorage::crypto::KeyStore::load(config.key_material())
.map_err(|error| error.to_string())?;
let service = OtpService::new(&repository, &keys);
let field = request.field;
match request.request {
OtpRequest::Code(code_request) => {
let uri = service
.uri(&code_request.entry, &mut provider)
.map_err(|error| error.to_string())?;
if uri.kind() == OtpKind::Hotp && !request.confirmed_hotp {
return Err("HOTP generation requires explicit confirmation".to_owned());
}
let unix_seconds = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|_| "the system clock is before the Unix epoch".to_owned())?
.as_secs();
let outcome = service
.code_automatic(&code_request.entry, unix_seconds, None, &mut provider)
.map_err(|error| error.to_string())?;
let remaining_seconds = outcome.remaining_at(unix_seconds);
let counter = outcome.counter();
let code = ironstorage::repository::SecretBytes::new(outcome.code().expose().to_vec());
let tree = counter.map(|_| load_tree(config)).transpose()?;
Ok(AsyncPayload::OtpCodeFinished {
entry: code_request.entry,
field,
code,
remaining_seconds,
counter,
clipboard: code_request.clipboard,
tree,
})
}
OtpRequest::Uri(uri_request) => {
let uri = service
.uri(&uri_request.entry, &mut provider)
.map_err(|error| error.to_string())?;
let payload =
ironstorage::repository::SecretBytes::new(uri.encoded().expose().to_vec());
let (presentation, qr) = match uri_request.presentation {
OtpUriPresentation::Terminal => (crate::app::OtpPresentationTarget::Terminal, None),
OtpUriPresentation::Clipboard => {
(crate::app::OtpPresentationTarget::Clipboard, None)
}
OtpUriPresentation::QrCode => (
crate::app::OtpPresentationTarget::Qr,
Some(QrMatrix::encode(&payload).map_err(|error| error.to_string())?),
),
};
Ok(AsyncPayload::OtpUriFinished {
entry: uri_request.entry,
presentation,
payload,
qr,
})
}
_ => Err("this OTP request is not a presentation operation".to_owned()),
}
}
fn load_startup() -> Result<StartupData, String> {
let config = ironstorage::config::Config::load(None).map_err(|error| error.to_string())?;
let repository = ironstorage::repository::Repository::open(config.vault())
@@ -769,6 +909,16 @@ fn apply_authentication_event(
execute_git(&config, request, Some(handle), &control)
});
}
AuthenticationEvent::Granted(AuthenticationTarget::Otp(request)) => {
let (Some(config), Some(handle)) = (app.config().cloned(), coordinator.handle()) else {
app.authentication_failed(
"authentication completed without an active secure-store lease".to_owned(),
);
return;
};
let token = app.begin_request();
executor.submit(token, move || execute_otp_ui(&config, request, handle));
}
AuthenticationEvent::Failed { workflow, message } => {
if workflow {
app.workflow_authentication_failed(message);
@@ -805,6 +955,7 @@ fn execute_workflow(
AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter, GitIdentity,
},
mutation::TreeMutator,
otp::OtpService,
recipient::RecipientPolicyManager,
write::VaultWriter,
};
@@ -926,6 +1077,53 @@ fn execute_workflow(
.map(|selection| (selection.display_path(), selection.is_directory()));
(None, format!("Copied {source} to {destination}"))
}
WorkflowSubmission::OtpInsert { request, input } => {
let service = OtpService::new(&repository, &keys);
let plan = service
.prepare_insert(&request, input)
.map_err(|error| error.to_string())?;
let path = plan.path().to_string();
let mut committer =
AutomaticEntryCommitter::for_entry(&repository, &path, GitIdentity::ironstorage())
.map_err(|error| error.to_string())?;
let outcome = service
.finish_insert(
plan,
ironstorage::write::OverwriteDecision::Allow,
ironstorage::write::OverwriteDecision::Allow,
None,
&mut committer,
)
.map_err(|error| error.to_string())?;
selection = Some((outcome.path().to_string(), false));
(None, format!("Inserted OTP URI at {}", outcome.path()))
}
WorkflowSubmission::OtpAppend { request, input } => {
let service = OtpService::new(&repository, &keys);
let session = service
.begin_append(&request, provider)
.map_err(|error| error.to_string())?;
let path = session.path().to_string();
let mut committer =
AutomaticEntryCommitter::for_entry(&repository, &path, GitIdentity::ironstorage())
.map_err(|error| error.to_string())?;
let outcome = service
.finish_append(
session,
input,
ironstorage::write::OverwriteDecision::Allow,
None,
&mut committer,
)
.map_err(|error| error.to_string())?;
selection = Some((outcome.path().to_string(), false));
(None, format!("Updated OTP URI at {}", outcome.path()))
}
WorkflowSubmission::OtpValidate { uri } => {
OtpService::validate_input(uri).map_err(|error| error.to_string())?;
refresh_tree = false;
(None, "OTP URI is valid".to_owned())
}
};
let tree = refresh_tree
.then(|| {

View File

@@ -36,6 +36,7 @@ pub enum AuthenticationTarget {
Entry(String),
Workflow(Box<WorkflowSubmission>),
Git(ironstorage::command::GitRequest),
Otp(crate::app::OtpUiRequest),
}
struct AuthenticationCompletion {
@@ -84,6 +85,10 @@ impl AuthenticationCoordinator {
self.request(AuthenticationTarget::Git(request));
}
pub fn request_otp(&mut self, request: crate::app::OtpUiRequest) {
self.request(AuthenticationTarget::Otp(request));
}
fn request(&mut self, target: AuthenticationTarget) {
self.generation = self.generation.wrapping_add(1);
let generation = self.generation;

View File

@@ -77,6 +77,43 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
return;
}
if let Some(matrix) = app.qr_popup() {
let padded_width = matrix.width() + 8;
let needed_width =
u16::try_from(padded_width.saturating_mul(2).saturating_add(2)).unwrap_or(u16::MAX);
let needed_height = u16::try_from(
padded_width
.next_multiple_of(2)
.saturating_div(2)
.saturating_add(2),
)
.unwrap_or(u16::MAX);
let text = if area.width < needed_width || area.height < needed_height {
format!(
"Terminal too small for OTP QR (need {needed_width}×{needed_height}); resize or Esc to close."
)
} else {
let rendered = matrix.render_terminal();
String::from_utf8_lossy(rendered.expose()).into_owned()
};
frame.render_widget(
Paragraph::new(text)
.block(Block::bordered().title("OTP QR — Esc closes"))
.wrap(Wrap { trim: false }),
area,
);
return;
}
if let Some(uri) = app.uri_popup() {
frame.render_widget(
Paragraph::new(String::from_utf8_lossy(uri.expose()).into_owned())
.block(Block::bordered().title("OTP URI — Esc closes"))
.wrap(Wrap { trim: false }),
area,
);
return;
}
if app.mode() == Mode::Help {
if let Some(help) = app.command_help() {
frame.render_widget(
@@ -87,27 +124,32 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
);
return;
}
let lines = help_actions(app.help_context_mode()).map(|spec| {
let bindings = spec
.bindings
.iter()
.map(|binding| binding.display)
let entries = help_actions(app.help_context_mode())
.map(|spec| {
let bindings = spec
.bindings
.iter()
.map(|binding| binding.display)
.collect::<Vec<_>>()
.join(", ");
format!(
"{bindings:>10} {:<20} :{:<16} {}",
spec.label,
spec.command,
spec.help()
)
})
.collect::<Vec<_>>();
let lines = if area.width >= 120 {
entries
.chunks(2)
.map(|chunk| Line::raw(chunk.join(" ")))
.collect::<Vec<_>>()
.join(", ");
let mut spans = vec![
Span::styled(format!("{bindings:>14}"), Style::default().fg(Color::Cyan)),
Span::raw(format!(" {:<24} :{:<18}", spec.label, spec.command)),
];
if !spec.help().is_empty() {
spans.push(Span::styled(
spec.help(),
Style::default().fg(Color::Yellow),
));
}
Line::from(spans)
});
} else {
entries.into_iter().map(Line::raw).collect::<Vec<_>>()
};
frame.render_widget(
Paragraph::new(lines.collect::<Vec<_>>())
Paragraph::new(lines)
.block(Block::bordered().title(format!(
"Contextual help — {}",
mode_title(app.help_context_mode())
@@ -157,7 +199,7 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
Mode::Viewer => app.viewer().map_or_else(
|| Paragraph::new(main_text(app)),
|viewer| {
Paragraph::new(viewer_lines(viewer))
Paragraph::new(viewer_lines(viewer, app.otp_display()))
.scroll((u16::try_from(viewer.scroll()).unwrap_or(u16::MAX), 0))
},
),
@@ -258,13 +300,19 @@ fn main_text(app: &App) -> String {
Mode::Dialog if app.discard_confirmation() => {
"Discard all unsaved edits? Press y to discard, n or Esc to keep editing.".to_owned()
}
Mode::Dialog if app.hotp_confirmation() => {
"Generate this HOTP code? This advances and commits its counter. Press y to continue, n or Esc to cancel.".to_owned()
}
Mode::Dialog => "Complete or cancel the active dialog.".to_owned(),
Mode::Command => "Enter a command on the bottom line.".to_owned(),
Mode::Help | Mode::Locked => String::new(),
}
}
fn viewer_lines(viewer: &EntryViewer) -> Vec<Line<'_>> {
fn viewer_lines<'a>(
viewer: &'a EntryViewer,
otp_display: Option<&'a crate::app::OtpDisplay>,
) -> Vec<Line<'a>> {
let focused = viewer.focused_index();
if viewer.document().fields().is_empty() {
return vec![Line::from("This entry is empty.")];
@@ -323,6 +371,28 @@ fn viewer_lines(viewer: &EntryViewer) -> Vec<Line<'_>> {
),
Style::default().fg(Color::DarkGray),
));
if focused == Some(index)
&& let Some(display) = otp_display
&& display
.field()
.is_none_or(|display_field| display_field == field.id())
{
let code = String::from_utf8_lossy(display.code().expose());
let validity = display.remaining_seconds().map_or_else(
|| {
display
.counter()
.map_or(String::new(), |counter| format!(", counter {counter}"))
},
|remaining| format!(", {remaining}s remaining"),
);
spans.push(Span::styled(
format!(" code {code}{validity}"),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
));
}
}
let line = Line::from(spans);
if focused == Some(index) {
@@ -856,6 +926,77 @@ mod tests {
assert!(!output.contains("JBSWY3DPEHPK3PXP"));
}
#[test]
fn otp_code_qr_resize_and_lock_lifecycle_are_secret_safe() {
let mut app = App::new();
let document = fixture_document("otp/totp");
let wrong_field = document.fields()[0].id();
let otp_field = document
.fields()
.iter()
.find(|field| field.metadata().otp().is_some())
.expect("OTP field")
.id();
app.open_test_document("otp/totp", document);
app.dispatch(crate::action::Action::FocusNext);
let token = app.begin_request();
app.apply_result(crate::app::AsyncResult {
token,
payload: Ok(crate::app::AsyncPayload::OtpCodeFinished {
entry: "otp/totp".to_owned(),
field: Some(wrong_field),
code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()),
remaining_seconds: Some(12),
counter: None,
clipboard: false,
tree: None,
}),
});
assert!(!render(120, 20, &app).contains("123456"));
let token = app.begin_request();
app.apply_result(crate::app::AsyncResult {
token,
payload: Ok(crate::app::AsyncPayload::OtpCodeFinished {
entry: "otp/totp".to_owned(),
field: Some(otp_field),
code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()),
remaining_seconds: Some(12),
counter: None,
clipboard: false,
tree: None,
}),
});
let code = render(120, 20, &app);
assert!(code.contains("123456"));
assert!(code.contains("12s remaining"));
assert!(!code.contains("JBSWY3DPEHPK3PXP"));
let payload = ironstorage::repository::SecretBytes::new(
b"otpauth://totp/test?secret=NEVER-RENDER".to_vec(),
);
let qr = ironstorage::presentation::QrMatrix::encode(&payload).expect("QR");
let token = app.begin_request();
app.apply_result(crate::app::AsyncResult {
token,
payload: Ok(crate::app::AsyncPayload::OtpUriFinished {
entry: "otp/totp".to_owned(),
presentation: crate::app::OtpPresentationTarget::Qr,
payload,
qr: Some(qr),
}),
});
let resized = render(40, 8, &app);
assert!(resized.contains("too small for OTP QR"));
assert!(!resized.contains("NEVER-RENDER"));
assert!(matches!(
app.dispatch(crate::action::Action::Lock),
crate::app::AppEffect::ManualLock
));
let locked = render(120, 20, &app);
assert!(!locked.contains("123456"));
assert!(!locked.contains("NEVER-RENDER"));
}
#[test]
fn closing_a_document_preserves_the_sidebar_selection() {
let mut app = App::new();

View File

@@ -6,9 +6,11 @@ use crossterm::event::{KeyCode, KeyModifiers};
use ironstorage::{
command::{
CopyRequest, GenerateRequest, GeneratedPresentation, GrepRequest, InitRequest, InsertInput,
InsertRequest, MoveRequest, RemoveRequest,
InsertRequest, MoveRequest, OtpAppendRequest, OtpInputSource, OtpInsertRequest,
RemoveRequest,
},
crypto::KeyInfo,
otp::OtpInput,
write::{InsertContent, OverwriteDecision},
};
use zeroize::Zeroize;
@@ -135,6 +137,23 @@ pub struct TransferForm {
focus: usize,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OtpFormKind {
Insert,
Append,
Validate,
}
#[derive(Debug)]
pub struct OtpForm {
kind: OtpFormKind,
entry: String,
uri: SecretText,
force: bool,
confirmed: bool,
focus: usize,
}
#[derive(Debug)]
pub enum WorkflowForm {
Init(InitForm),
@@ -144,6 +163,7 @@ pub enum WorkflowForm {
Remove(RemoveForm),
Move(TransferForm),
Copy(TransferForm),
Otp(OtpForm),
}
#[derive(Debug)]
@@ -168,6 +188,17 @@ pub enum WorkflowSubmission {
request: CopyRequest,
overwrite: OverwriteDecision,
},
OtpInsert {
request: OtpInsertRequest,
input: OtpInput,
},
OtpAppend {
request: OtpAppendRequest,
input: OtpInput,
},
OtpValidate {
uri: OtpInput,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -179,6 +210,16 @@ pub enum WorkflowInput {
}
impl WorkflowForm {
pub fn otp(kind: OtpFormKind, entry: Option<String>, force: bool) -> Self {
Self::Otp(OtpForm {
kind,
entry: entry.unwrap_or_default(),
uri: SecretText::default(),
force,
confirmed: false,
focus: 0,
})
}
pub fn init(keys: &[KeyInfo], default: Option<&KeyInfo>, request: Option<InitRequest>) -> Self {
let request = request.unwrap_or_else(|| InitRequest {
path: None,
@@ -328,6 +369,11 @@ impl WorkflowForm {
Self::Remove(_) => "Remove entry or folder",
Self::Move(_) => "Move or rename",
Self::Copy(_) => "Copy entry or folder",
Self::Otp(form) => match form.kind {
OtpFormKind::Insert => "Insert OTP URI",
OtpFormKind::Append => "Append OTP URI",
OtpFormKind::Validate => "Validate OTP URI",
},
}
}
@@ -424,6 +470,48 @@ impl WorkflowForm {
yes_no(form.overwrite),
),
],
Self::Otp(form) => {
vec![
row(
form.focus == 0,
"Entry",
if form.kind == OtpFormKind::Validate {
"(validation only)"
} else if form.entry.is_empty() {
"(derive from URI)"
} else {
&form.entry
},
),
row(
form.focus == 1,
"OTP URI",
if form.uri.is_empty() {
"(empty)"
} else {
"••••••••"
},
),
row(
form.focus == 2,
"Force replacement",
if form.kind == OtpFormKind::Validate {
"(not used)"
} else {
yes_no(form.force)
},
),
row(
form.focus == 3,
"Confirm write",
if form.kind == OtpFormKind::Validate {
"(not required)"
} else {
yes_no(form.confirmed)
},
),
]
}
}
}
@@ -573,6 +661,47 @@ impl WorkflowForm {
overwrite,
})
}
Self::Otp(form) => {
if form.uri.is_empty() {
return Err("OTP URI is required".to_owned());
}
let input = OtpInput::line(form.uri.bytes()).map_err(|error| error.to_string())?;
match form.kind {
OtpFormKind::Validate => Ok(WorkflowSubmission::OtpValidate { uri: input }),
OtpFormKind::Insert => {
if !form.confirmed {
return Err("Explicitly confirm the OTP write".to_owned());
}
Ok(WorkflowSubmission::OtpInsert {
request: OtpInsertRequest {
entry: (!form.entry.trim().is_empty())
.then(|| form.entry.trim().to_owned()),
force: form.force,
echo: false,
source: OtpInputSource::Uri,
},
input,
})
}
OtpFormKind::Append => {
if form.entry.trim().is_empty() {
return Err("Entry path is required".to_owned());
}
if !form.confirmed {
return Err("Explicitly confirm the OTP write".to_owned());
}
Ok(WorkflowSubmission::OtpAppend {
request: OtpAppendRequest {
entry: form.entry.trim().to_owned(),
force: form.force,
echo: false,
source: OtpInputSource::Uri,
},
input,
})
}
}
}
}
}
@@ -582,6 +711,7 @@ impl WorkflowForm {
Self::Insert(_) | Self::Generate(_) => 5,
Self::Grep(_) => 5,
Self::Remove(_) | Self::Move(_) | Self::Copy(_) => 4,
Self::Otp(_) => 4,
}
}
@@ -594,6 +724,7 @@ impl WorkflowForm {
Self::Grep(form) => &mut form.focus,
Self::Remove(form) => &mut form.focus,
Self::Move(form) | Self::Copy(form) => &mut form.focus,
Self::Otp(form) => &mut form.focus,
};
*focus = if forward {
(*focus + 1) % count
@@ -631,6 +762,8 @@ impl WorkflowForm {
form.destination_directory ^= true
}
Self::Move(form) | Self::Copy(form) if form.focus == 3 => form.overwrite ^= true,
Self::Otp(form) if form.focus == 2 => form.force ^= true,
Self::Otp(form) if form.focus == 3 => form.confirmed ^= true,
_ => self.character(' '),
}
}
@@ -674,6 +807,12 @@ impl WorkflowForm {
Self::Move(form) | Self::Copy(form) if form.focus == 1 => {
form.destination.pop();
}
Self::Otp(form) if form.focus == 0 => {
form.entry.pop();
}
Self::Otp(form) if form.focus == 1 => {
form.uri.pop();
}
_ => {}
}
}
@@ -702,6 +841,8 @@ impl WorkflowForm {
Self::Move(form) | Self::Copy(form) if form.focus == 1 => {
form.destination.push(character)
}
Self::Otp(form) if form.focus == 0 => form.entry.push(character),
Self::Otp(form) if form.focus == 1 && character != '\n' => form.uri.push(character),
_ => {}
}
}