Complete TUI coverage and security audit
This commit is contained in:
@@ -78,6 +78,7 @@ pub struct WorkflowSuccess {
|
||||
pub entry: Option<String>,
|
||||
pub document: Option<Box<EntryDocument>>,
|
||||
pub grep: Option<GrepResults>,
|
||||
pub presentation: Option<SecretPresentation>,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
@@ -98,6 +99,28 @@ pub struct OtpDisplay {
|
||||
counter: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct QrPopup {
|
||||
title: String,
|
||||
matrix: QrMatrix,
|
||||
}
|
||||
|
||||
impl QrPopup {
|
||||
pub fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
pub fn matrix(&self) -> &QrMatrix {
|
||||
&self.matrix
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SecretPresentation {
|
||||
Clipboard(SecretBytes),
|
||||
Qr { title: String, matrix: QrMatrix },
|
||||
}
|
||||
|
||||
impl OtpDisplay {
|
||||
pub fn entry(&self) -> &str {
|
||||
&self.entry
|
||||
@@ -196,6 +219,10 @@ pub enum AsyncPayload {
|
||||
qr: Option<QrMatrix>,
|
||||
},
|
||||
OtpValidated,
|
||||
SecretPresented {
|
||||
status: String,
|
||||
presentation: SecretPresentation,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -254,6 +281,7 @@ pub enum AppEffect {
|
||||
CancelGit,
|
||||
ResolveGit(Vec<ironstorage::git::GitConflictResolution>),
|
||||
AuthenticateOtp(OtpUiRequest),
|
||||
AuthenticateShow(ironstorage::command::ShowRequest),
|
||||
RunCommand(CommandRequest),
|
||||
ManualLock,
|
||||
}
|
||||
@@ -289,7 +317,7 @@ pub struct App {
|
||||
git_view: Option<GitView>,
|
||||
git_pending: bool,
|
||||
otp_display: Option<OtpDisplay>,
|
||||
qr_popup: Option<QrMatrix>,
|
||||
qr_popup: Option<QrPopup>,
|
||||
uri_popup: Option<SecretBytes>,
|
||||
otp_pending: bool,
|
||||
hotp_confirmation: Option<OtpUiRequest>,
|
||||
@@ -445,7 +473,7 @@ impl App {
|
||||
.is_some_and(|entry| entry == display.entry())
|
||||
})
|
||||
}
|
||||
pub fn qr_popup(&self) -> Option<&QrMatrix> {
|
||||
pub fn qr_popup(&self) -> Option<&QrPopup> {
|
||||
self.qr_popup.as_ref()
|
||||
}
|
||||
pub fn uri_popup(&self) -> Option<&SecretBytes> {
|
||||
@@ -717,6 +745,9 @@ impl App {
|
||||
if let Some((path, directory)) = success.selection {
|
||||
self.sidebar.select_path(&path, directory);
|
||||
}
|
||||
if let Some(presentation) = success.presentation {
|
||||
self.apply_secret_presentation(presentation);
|
||||
}
|
||||
self.status = success.status;
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -801,7 +832,10 @@ impl App {
|
||||
}
|
||||
OtpPresentationTarget::Qr => {
|
||||
self.uri_popup = None;
|
||||
self.qr_popup = qr;
|
||||
self.qr_popup = qr.map(|matrix| QrPopup {
|
||||
title: "OTP QR".to_owned(),
|
||||
matrix,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -809,6 +843,13 @@ impl App {
|
||||
self.otp_pending = false;
|
||||
self.status = "OTP URI is valid".to_owned();
|
||||
}
|
||||
Ok(AsyncPayload::SecretPresented {
|
||||
status,
|
||||
presentation,
|
||||
}) => {
|
||||
self.apply_secret_presentation(presentation);
|
||||
self.status = status;
|
||||
}
|
||||
Err(error) => {
|
||||
self.git_pending = false;
|
||||
self.otp_pending = false;
|
||||
@@ -1295,6 +1336,49 @@ impl App {
|
||||
Some(AppEffect::None)
|
||||
}
|
||||
|
||||
/// Insert bracketed paste as inert text into the active input surface.
|
||||
/// Control characters are rejected so paste can never submit a command or
|
||||
/// silently create additional password-store lines.
|
||||
pub fn handle_paste(&mut self, text: &str) -> bool {
|
||||
if text.chars().any(char::is_control) {
|
||||
self.status = "Paste rejected: control characters are not accepted".to_owned();
|
||||
return matches!(
|
||||
self.mode,
|
||||
Mode::Command | Mode::Editor | Mode::Dialog | Mode::Browser
|
||||
);
|
||||
}
|
||||
if self.mode == Mode::Command {
|
||||
for character in text.chars() {
|
||||
self.command_line.insert(character);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if self.mode == Mode::Dialog
|
||||
&& let Some(workflow) = self.workflow.as_mut()
|
||||
{
|
||||
for character in text.chars() {
|
||||
workflow.handle_key(
|
||||
crossterm::event::KeyCode::Char(character),
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if self.mode == Mode::Editor {
|
||||
for character in text.chars() {
|
||||
self.handle_editor_input(crossterm::event::KeyCode::Char(character));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if self.sidebar.is_editing_filter() {
|
||||
for character in text.chars() {
|
||||
self.sidebar.push_filter_character(character);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn submit_command(&mut self) -> AppEffect {
|
||||
let invocation = match self.command_line.submit() {
|
||||
Ok(invocation) => invocation,
|
||||
@@ -1439,6 +1523,11 @@ impl App {
|
||||
AppEffect::None
|
||||
}
|
||||
}
|
||||
CommandInvocation::Storage(CommandRequest::Show(request)) => {
|
||||
self.transition(Transition::Dismiss);
|
||||
self.status = "Authenticating for secret presentation…".to_owned();
|
||||
AppEffect::AuthenticateShow(request)
|
||||
}
|
||||
CommandInvocation::Storage(CommandRequest::Edit(request)) => {
|
||||
if context == Mode::Editor {
|
||||
if self.selected_entry.as_deref() == Some(&request.entry) {
|
||||
@@ -1537,6 +1626,16 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_secret_presentation(&mut self, presentation: SecretPresentation) {
|
||||
match presentation {
|
||||
SecretPresentation::Clipboard(value) => self.clipboard_request = Some(value),
|
||||
SecretPresentation::Qr { title, matrix } => {
|
||||
self.uri_popup = None;
|
||||
self.qr_popup = Some(QrPopup { title, matrix });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn open_workflow(&mut self, workflow: WorkflowAction, request: Option<CommandRequest>) {
|
||||
let form = match (workflow, request) {
|
||||
(WorkflowAction::Initialize, Some(CommandRequest::Init(request))) => {
|
||||
@@ -2250,6 +2349,15 @@ mod tests {
|
||||
));
|
||||
assert_eq!(app.mode(), Mode::Browser);
|
||||
|
||||
let mut app = App::new();
|
||||
assert!(matches!(
|
||||
enter_command(&mut app, "show --clip=2 email/personal"),
|
||||
AppEffect::AuthenticateShow(ironstorage::command::ShowRequest {
|
||||
entry: Some(entry),
|
||||
presentation: Presentation::Clipboard { .. },
|
||||
}) if entry == "email/personal"
|
||||
));
|
||||
|
||||
app.open_test_document("email/personal", fixture_document("email/personal"));
|
||||
assert!(matches!(
|
||||
enter_command(&mut app, "edit email/personal"),
|
||||
@@ -2397,6 +2505,37 @@ mod tests {
|
||||
assert!(!app.status().contains(secret));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bracketed_paste_is_inert_unicode_text_and_rejects_submission_controls() {
|
||||
let mut app = App::new();
|
||||
app.dispatch(Action::Command);
|
||||
assert!(app.handle_paste("show unicode/咖啡"));
|
||||
assert_eq!(app.command_line().input(), "show unicode/咖啡");
|
||||
assert!(app.handle_paste("\nremove everything"));
|
||||
assert_eq!(app.command_line().input(), "show unicode/咖啡");
|
||||
assert!(app.status().contains("control characters"));
|
||||
|
||||
app.dispatch(Action::Cancel);
|
||||
app.dispatch(Action::Filter);
|
||||
assert!(app.handle_paste("咖啡"));
|
||||
assert_eq!(app.sidebar().filter_query(), "咖啡");
|
||||
|
||||
let mut app = App::new();
|
||||
app.dispatch(Action::InsertEntry);
|
||||
app.handle_workflow_input(
|
||||
crossterm::event::KeyCode::Tab,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
);
|
||||
app.handle_workflow_input(
|
||||
crossterm::event::KeyCode::Tab,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
);
|
||||
assert!(app.handle_paste("pasted-secret"));
|
||||
let rows = app.workflow().expect("insert form").rows().join("\n");
|
||||
assert!(rows.contains("••••••••"));
|
||||
assert!(!rows.contains("pasted-secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hotp_requires_confirmation_and_uri_forms_remain_masked() {
|
||||
let mut app = App::new();
|
||||
|
||||
Reference in New Issue
Block a user