Implement OTP clipboard and QR TUI
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user