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