Complete TUI coverage and security audit
This commit is contained in:
@@ -24,6 +24,7 @@ use std::{
|
||||
|
||||
use crossterm::event::{self, Event, KeyEventKind};
|
||||
use ratatui::DefaultTerminal;
|
||||
use zeroize::Zeroize as _;
|
||||
|
||||
use crate::{
|
||||
action::{KeyResolution, KeyResolver},
|
||||
@@ -72,6 +73,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
let mut git_control = None;
|
||||
let mut authentication_initialized = false;
|
||||
let mut key_resolver = KeyResolver::default();
|
||||
let color_capability = ui::ColorCapability::detect();
|
||||
let startup = app.begin_latest_request();
|
||||
executor.submit(startup, || {
|
||||
load_startup().map(|startup| AsyncPayload::Startup(Box::new(startup)))
|
||||
@@ -111,7 +113,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
|
||||
let size = terminal.size()?;
|
||||
app.resize(size.width, size.height);
|
||||
terminal.draw(|frame| ui::draw(frame, &app))?;
|
||||
terminal.draw(|frame| ui::draw_with_color_capability(frame, &app, color_capability))?;
|
||||
if !event::poll(TICK_INTERVAL)? {
|
||||
app.tick();
|
||||
if let Some((entry, field)) = app.begin_totp_refresh() {
|
||||
@@ -231,11 +233,31 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
}
|
||||
}
|
||||
Event::Resize(width, height) => app.resize(width, height),
|
||||
Event::FocusLost => {
|
||||
if let Some(coordinator) = authentication.as_mut() {
|
||||
let _ignored = coordinator.lock();
|
||||
Event::Paste(mut pasted) => {
|
||||
if let Some(coordinator) = authentication.as_mut()
|
||||
&& let Some(event) = coordinator.touch_user_activity()
|
||||
{
|
||||
apply_authentication_event(
|
||||
&mut app,
|
||||
coordinator,
|
||||
&executor,
|
||||
&mut git_control,
|
||||
event,
|
||||
);
|
||||
}
|
||||
app.forced_relock("terminal ownership was lost");
|
||||
let filtering = app.sidebar().is_editing_filter();
|
||||
if app.handle_paste(&pasted) && filtering {
|
||||
submit_filter(&mut app, &executor);
|
||||
}
|
||||
pasted.zeroize();
|
||||
}
|
||||
Event::FocusLost => {
|
||||
handle_terminal_ownership_lost(
|
||||
&mut app,
|
||||
&mut authentication,
|
||||
&mut git_control,
|
||||
&mut clipboard_cancellations,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -243,6 +265,22 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_terminal_ownership_lost(
|
||||
app: &mut App,
|
||||
authentication: &mut Option<AuthenticationCoordinator>,
|
||||
git_control: &mut Option<ironstorage::git::GitOperationControl>,
|
||||
clipboard_cancellations: &mut ClipboardCancellations,
|
||||
) {
|
||||
clipboard_cancellations.cancel_all();
|
||||
if let Some(control) = git_control.take() {
|
||||
control.cancel();
|
||||
}
|
||||
if let Some(coordinator) = authentication.as_mut() {
|
||||
let _ignored = coordinator.lock();
|
||||
}
|
||||
app.forced_relock("terminal ownership was lost");
|
||||
}
|
||||
|
||||
fn apply_app_effect(
|
||||
app: &mut App,
|
||||
effect: AppEffect,
|
||||
@@ -299,6 +337,15 @@ fn apply_app_effect(
|
||||
);
|
||||
}
|
||||
}
|
||||
AppEffect::AuthenticateShow(request) => {
|
||||
if let Some(coordinator) = authentication.as_mut() {
|
||||
coordinator.request_show(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();
|
||||
@@ -823,6 +870,41 @@ fn execute_otp_ui(
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_show_presentation(
|
||||
config: &ironstorage::config::Config,
|
||||
request: ironstorage::command::ShowRequest,
|
||||
mut provider: impl ironstorage::crypto::SecretProvider,
|
||||
) -> Result<AsyncPayload, String> {
|
||||
use ironstorage::{
|
||||
presentation::QrMatrix,
|
||||
read::{PresentationChannel, ShowOutput, VaultReader},
|
||||
};
|
||||
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 ShowOutput::Present(secret) = VaultReader::new(&repository, &keys)
|
||||
.execute_show(&request, &mut provider)
|
||||
.map_err(|error| error.to_string())?
|
||||
else {
|
||||
return Err("the show request did not select a presentation channel".to_owned());
|
||||
};
|
||||
let status = format!("Presented {} line {}", secret.entry(), secret.line());
|
||||
let presentation = match secret.channel() {
|
||||
PresentationChannel::Clipboard => crate::app::SecretPresentation::Clipboard(
|
||||
ironstorage::repository::SecretBytes::new(secret.contents().expose().to_vec()),
|
||||
),
|
||||
PresentationChannel::QrCode => crate::app::SecretPresentation::Qr {
|
||||
title: "Entry QR".to_owned(),
|
||||
matrix: QrMatrix::encode(secret.contents()).map_err(|error| error.to_string())?,
|
||||
},
|
||||
};
|
||||
Ok(AsyncPayload::SecretPresented {
|
||||
status,
|
||||
presentation,
|
||||
})
|
||||
}
|
||||
|
||||
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())
|
||||
@@ -919,6 +1001,18 @@ fn apply_authentication_event(
|
||||
let token = app.begin_request();
|
||||
executor.submit(token, move || execute_otp_ui(&config, request, handle));
|
||||
}
|
||||
AuthenticationEvent::Granted(AuthenticationTarget::Show(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_show_presentation(&config, request, handle)
|
||||
});
|
||||
}
|
||||
AuthenticationEvent::Failed { workflow, message } => {
|
||||
if workflow {
|
||||
app.workflow_authentication_failed(message);
|
||||
@@ -967,6 +1061,7 @@ fn execute_workflow(
|
||||
let identity = GitIdentity::ironstorage();
|
||||
let mut selection = None;
|
||||
let mut grep = None;
|
||||
let mut presentation = None;
|
||||
let mut refresh_tree = true;
|
||||
let (entry, mut status) = match submission {
|
||||
WorkflowSubmission::Init(request) => {
|
||||
@@ -1007,7 +1102,23 @@ fn execute_workflow(
|
||||
PasswordGenerator::new(&repository, &keys, GeneratorConfig::pass_defaults())
|
||||
.generate(&request, overwrite, None, provider, &mut committer)
|
||||
.map_err(|error| error.to_string())?;
|
||||
drop(outcome);
|
||||
presentation = match outcome.channel() {
|
||||
ironstorage::generate::GeneratedChannel::Terminal => None,
|
||||
ironstorage::generate::GeneratedChannel::Clipboard => {
|
||||
Some(crate::app::SecretPresentation::Clipboard(
|
||||
ironstorage::repository::SecretBytes::new(
|
||||
outcome.password().expose().to_vec(),
|
||||
),
|
||||
))
|
||||
}
|
||||
ironstorage::generate::GeneratedChannel::QrCode => {
|
||||
Some(crate::app::SecretPresentation::Qr {
|
||||
title: "Generated password QR".to_owned(),
|
||||
matrix: ironstorage::presentation::QrMatrix::encode(outcome.password())
|
||||
.map_err(|error| error.to_string())?,
|
||||
})
|
||||
}
|
||||
};
|
||||
(
|
||||
Some(entry.clone()),
|
||||
format!("Generated password for {entry}"),
|
||||
@@ -1155,6 +1266,7 @@ fn execute_workflow(
|
||||
entry,
|
||||
document,
|
||||
grep,
|
||||
presentation,
|
||||
status,
|
||||
})
|
||||
}
|
||||
@@ -1270,7 +1382,7 @@ mod tests {
|
||||
use ironstorage::{
|
||||
command::{
|
||||
CopyRequest, GenerateRequest, GeneratedPresentation, GrepRequest, InitRequest,
|
||||
InsertInput, InsertRequest, MoveRequest, RemoveRequest,
|
||||
InsertInput, InsertRequest, MoveRequest, Presentation, RemoveRequest, ShowRequest,
|
||||
},
|
||||
crypto::{KeyInfo, SecretProvider, SecretProviderError},
|
||||
repository::SecretBytes,
|
||||
@@ -1287,6 +1399,149 @@ mod tests {
|
||||
assert!(!is_dispatchable_key_kind(KeyEventKind::Release));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_tui_sources_preserve_the_presentation_only_boundary() {
|
||||
let sources = [
|
||||
("action.rs", include_str!("action.rs")),
|
||||
("app.rs", include_str!("app.rs")),
|
||||
("command.rs", include_str!("command.rs")),
|
||||
("editor.rs", include_str!("editor.rs")),
|
||||
("lib.rs", include_str!("lib.rs")),
|
||||
("runtime.rs", include_str!("runtime.rs")),
|
||||
("search.rs", include_str!("search.rs")),
|
||||
("sidebar.rs", include_str!("sidebar.rs")),
|
||||
("terminal.rs", include_str!("terminal.rs")),
|
||||
("ui.rs", include_str!("ui.rs")),
|
||||
("viewer.rs", include_str!("viewer.rs")),
|
||||
("workflow.rs", include_str!("workflow.rs")),
|
||||
];
|
||||
for (name, source) in sources {
|
||||
let production = source.split("#[cfg(test)]").next().unwrap_or(source);
|
||||
let forbidden_tokens = [
|
||||
["std::", "process"].concat(),
|
||||
["process", "::Command"].concat(),
|
||||
["Command", "::new("].concat(),
|
||||
["std::", "fs::"].concat(),
|
||||
["fs::", "read("].concat(),
|
||||
["fs::", "write("].concat(),
|
||||
[".read", "_entry("].concat(),
|
||||
[".write", "_entry("].concat(),
|
||||
["OtpUri", "::parse"].concat(),
|
||||
["qrcode", "::QrCode"].concat(),
|
||||
["unsafe", " {"].concat(),
|
||||
];
|
||||
for forbidden in &forbidden_tokens {
|
||||
assert!(
|
||||
!production.contains(forbidden),
|
||||
"{name} crosses the TUI architecture boundary with {forbidden}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_ownership_loss_cancels_presentations_and_forces_relock() {
|
||||
let mut app = App::new();
|
||||
let mut authentication = None;
|
||||
let mut git_control = None;
|
||||
let mut clipboard = ClipboardCancellations::default();
|
||||
let (cancel, cancelled) = std::sync::mpsc::channel();
|
||||
clipboard.register(cancel);
|
||||
|
||||
handle_terminal_ownership_lost(
|
||||
&mut app,
|
||||
&mut authentication,
|
||||
&mut git_control,
|
||||
&mut clipboard,
|
||||
);
|
||||
|
||||
assert_eq!(app.mode(), crate::app::Mode::Locked);
|
||||
assert!(app.status().contains("terminal ownership was lost"));
|
||||
cancelled
|
||||
.recv_timeout(std::time::Duration::from_secs(1))
|
||||
.expect("clipboard cancellation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_and_generate_presentation_requests_reach_clipboard_and_qr_outputs() {
|
||||
let temporary = tempfile::tempdir().expect("temporary presentation store");
|
||||
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../crates/storage/tests/fixtures/compatibility");
|
||||
let store = temporary.path().join("store");
|
||||
copy_directory(&fixtures.join("stores/basic"), &store);
|
||||
let config_path = temporary.path().join("config.toml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
format!(
|
||||
"vault = {:?}\ndefault_key = \"7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30\"\nkey_material = {:?}\n",
|
||||
store,
|
||||
fixtures.join("keys"),
|
||||
),
|
||||
)
|
||||
.expect("write config");
|
||||
let config = ironstorage::config::Config::load(Some(&config_path)).expect("config");
|
||||
|
||||
let shown = execute_show_presentation(
|
||||
&config,
|
||||
ShowRequest {
|
||||
entry: Some("email/personal".to_owned()),
|
||||
presentation: Presentation::Clipboard {
|
||||
line: std::num::NonZeroUsize::new(1).expect("nonzero"),
|
||||
},
|
||||
},
|
||||
FixtureSecrets,
|
||||
)
|
||||
.expect("show clipboard");
|
||||
assert!(matches!(
|
||||
shown,
|
||||
AsyncPayload::SecretPresented {
|
||||
presentation: crate::app::SecretPresentation::Clipboard(secret),
|
||||
..
|
||||
} if secret.expose() == b"correct horse fixture"
|
||||
));
|
||||
|
||||
let shown_qr = execute_show_presentation(
|
||||
&config,
|
||||
ShowRequest {
|
||||
entry: Some("email/personal".to_owned()),
|
||||
presentation: Presentation::QrCode {
|
||||
line: std::num::NonZeroUsize::new(1).expect("nonzero"),
|
||||
},
|
||||
},
|
||||
FixtureSecrets,
|
||||
)
|
||||
.expect("show QR");
|
||||
assert!(matches!(
|
||||
shown_qr,
|
||||
AsyncPayload::SecretPresented {
|
||||
presentation: crate::app::SecretPresentation::Qr { matrix, .. },
|
||||
..
|
||||
} if matrix.width() > 0
|
||||
));
|
||||
|
||||
let generated = execute_workflow(
|
||||
&config,
|
||||
WorkflowSubmission::Generate {
|
||||
request: GenerateRequest {
|
||||
entry: "generated/tui-clipboard".to_owned(),
|
||||
length: Some(std::num::NonZeroUsize::new(24).expect("nonzero")),
|
||||
no_symbols: true,
|
||||
force: false,
|
||||
in_place: false,
|
||||
presentation: GeneratedPresentation::Clipboard,
|
||||
},
|
||||
overwrite: OverwriteDecision::Decline,
|
||||
},
|
||||
&mut FixtureSecrets,
|
||||
)
|
||||
.expect("generate clipboard");
|
||||
assert!(matches!(
|
||||
generated.presentation,
|
||||
Some(crate::app::SecretPresentation::Clipboard(secret))
|
||||
if secret.expose().len() == 24
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_save_encrypts_and_automatically_commits_entirely_in_storage() {
|
||||
let temporary = tempfile::tempdir().expect("temporary editor store");
|
||||
|
||||
Reference in New Issue
Block a user