#![forbid(unsafe_code)] #![deny(clippy::disallowed_types)] use std::{ error::Error, ffi::OsString, fmt, io::{BufRead as _, IsTerminal as _, Read as _, Write}, path::{Path, PathBuf}, process::ExitCode, sync::{ Arc, atomic::{AtomicBool, Ordering}, }, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use ironstorage::{ command::{ CliAction, CommandRequest, EXIT_CONFIG, EXIT_FAILURE, EXIT_SUCCESS, EXIT_UNAVAILABLE, GeneratedPresentation, GitConfigRequest, GitRemoteRequest, GitRequest, HelpTopic, InputPlan, OtpInputSource, OtpRequest, OtpUriPresentation, completion_script, help_text, otp_version_text, parse_from, version_text, }, config::Config, crypto::KeyStore, generate::{GeneratorConfig, PasswordGenerator}, git::{GitChangeKind, GitIdentity, GitRepository, PullOutcome}, mutation::{ MutationError, NoGitTreeCommitter, TreeCommit, TreeCommitError, TreeCommitter, TreeMutator, }, otp::{OtpError, OtpInput, OtpService}, presentation::{ ClipboardError, ClipboardTimeout, ClipboardWait, NativeClipboardManager, QrError, QrMatrix, }, read::{PresentationChannel, ShowOutput, ShowResult, VaultReader}, recipient::{ NoGitCommitter, PolicyCommit, PolicyCommitError, PolicyCommitter, RecipientPolicyManager, }, repository::{DirectoryPath, Repository, SecretBytes}, secret_store::{ NativeSecretStore, OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError, SecretCachePolicy, SecretProtectionPolicy, SecretStore, SecretStoreBackend, }, write::{ EntryCommit, EntryCommitError, EntryCommitter, InsertContent, NoGitEntryCommitter, OverwriteDecision, VaultWriter, }, }; #[allow(dead_code)] mod editor; fn main() -> ExitCode { match run() { Ok(code) => ExitCode::from(code), Err(()) => ExitCode::from(ironstorage::command::EXIT_FAILURE), } } fn run() -> Result { let stdout = std::io::stdout(); let stderr = std::io::stderr(); run_with(std::env::args_os(), stdout.lock(), stderr.lock()) } fn run_with(arguments: I, mut stdout: O, mut stderr: E) -> Result where I: IntoIterator, T: Into + Clone, O: Write, E: Write, { let action = match parse_from(arguments) { Ok(action) => action, Err(error) => { stderr .write_all(error.to_string().as_bytes()) .map_err(|_| ())?; return Ok(error.exit_code()); } }; match action { CliAction::Display(text) => { stdout.write_all(text.as_bytes()).map_err(|_| ())?; Ok(EXIT_SUCCESS) } CliAction::Run(invocation) => match invocation.request() { CommandRequest::Help { topic } => { stdout .write_all(help_text(*topic).as_bytes()) .map_err(|_| ())?; Ok(EXIT_SUCCESS) } CommandRequest::Version => { stdout .write_all(version_text().as_bytes()) .map_err(|_| ())?; Ok(EXIT_SUCCESS) } CommandRequest::Otp(OtpRequest::Help) => { stdout .write_all(help_text(Some(HelpTopic::Otp)).as_bytes()) .map_err(|_| ())?; Ok(EXIT_SUCCESS) } CommandRequest::Otp(OtpRequest::Version) => { stdout .write_all(otp_version_text().as_bytes()) .map_err(|_| ())?; Ok(EXIT_SUCCESS) } CommandRequest::Otp(OtpRequest::Validate { uri }) => match OtpService::validate(uri) { Ok(()) => Ok(EXIT_SUCCESS), Err(error) => operation_error(&mut stderr, error), }, CommandRequest::Completion { shell } => { stdout .write_all(&completion_script(*shell)) .map_err(|_| ())?; Ok(EXIT_SUCCESS) } request => match Config::load(invocation.config()) { Ok(config) if needs_secret_store(request) => { let mut secrets = match NativeSecretStore::system( SecretCachePolicy::Disabled, SecretProtectionPolicy::device_unlocked(), ) .and_then(|store| { store.unlock()?; Ok(store.with_openpgp_passphrase_prompt(NativeOpenPgpPassphrasePrompt)) }) { Ok(secrets) => secrets, Err(error) => { writeln!(stderr, "{error}").map_err(|_| ())?; return Ok(EXIT_UNAVAILABLE); } }; execute_secure(&config, request, &mut secrets, &mut stdout, &mut stderr) } Ok(config) => execute_local(&config, request, &mut stdout, &mut stderr), Err(error) => { writeln!(stderr, "{error}").map_err(|_| ())?; Ok(EXIT_CONFIG) } }, }, } } fn needs_secret_store(request: &CommandRequest) -> bool { matches!( request, CommandRequest::Show(_) | CommandRequest::Init(_) | CommandRequest::Grep(_) | CommandRequest::Insert(_) | CommandRequest::Edit(_) | CommandRequest::Generate(_) | CommandRequest::Remove(_) | CommandRequest::Move(_) | CommandRequest::Copy(_) | CommandRequest::Otp( OtpRequest::Code(_) | OtpRequest::Insert(_) | OtpRequest::Append(_) | OtpRequest::Uri(_), ) | CommandRequest::Git( GitRequest::Diff { .. } | GitRequest::Fetch { .. } | GitRequest::Pull { .. } | GitRequest::Push { .. } | GitRequest::Sync { .. }, ) ) } fn execute_local( config: &Config, request: &CommandRequest, stdout: &mut O, stderr: &mut E, ) -> Result { let repository = match Repository::open(config.vault()) { Ok(repository) => repository, Err(error) => return operation_error(stderr, error), }; match request { CommandRequest::List(request) => { let directory = match DirectoryPath::parse(request.path.as_deref().unwrap_or_default()) { Ok(directory) => directory, Err(error) => return operation_error(stderr, error), }; let keys = match KeyStore::load(config.key_material()) { Ok(keys) => keys, Err(error) => return operation_error(stderr, error), }; match VaultReader::new(&repository, &keys).list(&directory) { Ok(tree) => { stdout .write_all(tree.render_plain().as_bytes()) .map_err(|_| ())?; Ok(EXIT_SUCCESS) } Err(error) => operation_error(stderr, error), } } CommandRequest::Find(request) => { let keys = match KeyStore::load(config.key_material()) { Ok(keys) => keys, Err(error) => return operation_error(stderr, error), }; match VaultReader::new(&repository, &keys).find(&request.terms) { Ok(results) => { stdout .write_all(results.render_plain().as_bytes()) .map_err(|_| ())?; Ok(EXIT_SUCCESS) } Err(error) => operation_error(stderr, error), } } CommandRequest::Git(request) => execute_local_git(&repository, request, stdout, stderr), _ => operation_error(stderr, "the command requires authenticated secret access"), } } fn execute_local_git( repository: &Repository, request: &GitRequest, stdout: &mut O, stderr: &mut E, ) -> Result { if matches!(request, GitRequest::Init) { return match GitRepository::init(repository, git_identity()) { Ok(_) => Ok(EXIT_SUCCESS), Err(error) => operation_error(stderr, error), }; } let mut git = match GitRepository::open(repository, git_identity()) { Ok(git) => git, Err(error) => return operation_error(stderr, error), }; let result = match request { GitRequest::Status => git.status().and_then(|status| { if status.is_clean() { writeln!(stdout, "clean").map_err(|_| output_git_error())?; } else { for change in status.staged() { writeln!( stdout, "staged\t{}\t{}", git_change_name(change.kind()), change.path().display() ) .map_err(|_| output_git_error())?; } for change in status.unstaged() { writeln!( stdout, "unstaged\t{}\t{}", git_change_name(change.kind()), change.path().display() ) .map_err(|_| output_git_error())?; } } Ok(()) }), GitRequest::Log { maximum } => git.log(maximum.map(usize::from)).and_then(|entries| { for entry in entries { writeln!(stdout, "commit {}", entry.id()).map_err(|_| output_git_error())?; writeln!( stdout, "Author: {} <{}>", entry.author_name(), entry.author_email() ) .map_err(|_| output_git_error())?; writeln!(stdout, "Date: {}", entry.timestamp()).map_err(|_| output_git_error())?; writeln!(stdout, "\n {}\n", entry.message().trim_end()) .map_err(|_| output_git_error())?; } Ok(()) }), GitRequest::Diff { paths } => git .diff(&paths.iter().map(PathBuf::from).collect::>()) .and_then(|entries| { for entry in entries { let kind = match (entry.old(), entry.current()) { (None, Some(_)) => "added", (Some(_), None) => "deleted", _ => "modified", }; writeln!(stdout, "{kind}\t{}", entry.path().display()) .map_err(|_| output_git_error())?; } Ok(()) }), GitRequest::Add { paths } => { git.stage(&paths.iter().map(PathBuf::from).collect::>()) } GitRequest::Commit { message } => git.commit(message).and_then(|id| { writeln!(stdout, "{id}").map_err(|_| output_git_error())?; Ok(()) }), GitRequest::Remote(request) => match request { GitRemoteRequest::List => { for remote in git.remotes() { writeln!(stdout, "{remote}").map_err(|_| ())?; } return Ok(EXIT_SUCCESS); } GitRemoteRequest::GetUrl { name } => git.remote_url(name).and_then(|url| { writeln!(stdout, "{url}").map_err(|_| output_git_error())?; Ok(()) }), GitRemoteRequest::Add { name, url } => git.add_remote(name, url), GitRemoteRequest::SetUrl { name, url } => git.set_remote_url(name, url), GitRemoteRequest::Remove { name } => git.remove_remote(name), }, GitRequest::Config(request) => match request { GitConfigRequest::Get { key } => git.config_get(key).and_then(|value| { if let Some(value) = value { writeln!(stdout, "{value}").map_err(|_| output_git_error())?; } Ok(()) }), GitConfigRequest::Set { key, value } => git.config_set(key, value), }, GitRequest::Init | GitRequest::Fetch { .. } | GitRequest::Pull { .. } | GitRequest::Push { .. } | GitRequest::Sync { .. } => unreachable!("routed before local Git execution"), }; match result { Ok(()) => Ok(EXIT_SUCCESS), Err(error) => operation_error(stderr, error), } } fn git_change_name(kind: GitChangeKind) -> &'static str { match kind { GitChangeKind::Added => "added", GitChangeKind::Modified => "modified", GitChangeKind::Deleted => "deleted", } } fn output_git_error() -> ironstorage::git::GitError { ironstorage::git::GitError::Io { operation: "write command output", path: PathBuf::from("stdout"), } } fn git_identity() -> GitIdentity { GitIdentity::new("IronStorage", "ironstorage@localhost") .expect("the built-in Git identity is valid") } fn execute_secure( config: &Config, request: &CommandRequest, secrets: &mut SecretStore, stdout: &mut O, stderr: &mut E, ) -> Result { execute_secure_with_services( config, request, secrets, &mut NativePresentation, &mut NativeOtpInteraction, current_unix_seconds, stdout, stderr, ) } #[cfg(test)] fn execute_secure_with( config: &Config, request: &CommandRequest, secrets: &mut SecretStore, presentation: &mut P, stdout: &mut O, stderr: &mut E, ) -> Result { execute_secure_with_services( config, request, secrets, presentation, &mut UnavailableOtpInteraction, current_unix_seconds, stdout, stderr, ) } #[allow(clippy::too_many_arguments)] fn execute_secure_with_services< B: SecretStoreBackend, P: CliPresentation, I: CliInteraction, O: Write, E: Write, >( config: &Config, request: &CommandRequest, secrets: &mut SecretStore, presentation: &mut P, interaction: &mut I, clock: impl Fn() -> Result, stdout: &mut O, stderr: &mut E, ) -> Result { let repository = match Repository::open(config.vault()) { Ok(repository) => repository, Err(error) => return operation_error(stderr, error), }; let keys = match KeyStore::load(config.key_material()) { Ok(keys) => keys, Err(error) => return operation_error(stderr, error), }; match request { CommandRequest::Init(request) => { let path = request.path.as_deref().unwrap_or_default(); let mut committer = match automatic_committer(&repository, path) { Ok(committer) => committer, Err(error) => return operation_error(stderr, error), }; match RecipientPolicyManager::new(&repository, &keys).apply_init( request, None, secrets, &mut committer, ) { Ok(_) => Ok(EXIT_SUCCESS), Err(error) => operation_error(stderr, error), } } CommandRequest::Show(request) => { match VaultReader::new(&repository, &keys).execute_show(request, secrets) { Ok(ShowOutput::Display(ShowResult::Entry(secret))) => { stdout.write_all(secret.expose()).map_err(|_| ())?; Ok(EXIT_SUCCESS) } Ok(ShowOutput::Display(ShowResult::Directory(tree))) => { stdout .write_all(tree.render_plain().as_bytes()) .map_err(|_| ())?; Ok(EXIT_SUCCESS) } Ok(ShowOutput::Present(secret)) => { let description = format!("{} line {}", secret.entry(), secret.line()); let result = match secret.channel() { PresentationChannel::Clipboard => presentation.clipboard( secret.contents(), config.clipboard_timeout(), &description, stdout, ), PresentationChannel::QrCode => { presentation.qr_code(secret.contents(), stdout) } }; match result { Ok(()) => Ok(EXIT_SUCCESS), Err(error) => operation_error(stderr, error), } } Err(error) => operation_error(stderr, error), } } CommandRequest::Grep(request) => { match VaultReader::new(&repository, &keys).grep(request, secrets) { Ok(results) => { stdout .write_all(results.render_plain().expose()) .map_err(|_| ())?; Ok(if results.is_empty() { EXIT_FAILURE } else { EXIT_SUCCESS }) } Err(error) => operation_error(stderr, error), } } CommandRequest::Insert(request) => { let writer = VaultWriter::new(&repository, &keys); let exists = match writer.entry_exists(&request.entry) { Ok(exists) => exists, Err(error) => return operation_error(stderr, error), }; let overwrite = if exists && !request.force { match interaction.confirm( &format!( "An entry already exists for {}. Overwrite it?", request.entry ), stderr, ) { Ok(decision) => decision, Err(error) => return operation_error(stderr, error), } } else { OverwriteDecision::Allow }; if overwrite == OverwriteDecision::Decline { return operation_error(stderr, "entry overwrite was declined"); } let contents = match interaction.read_insert( request.input_plan(interaction.standard_input_is_terminal()), &request.entry, stderr, ) { Ok(contents) => contents, Err(error) => return operation_error(stderr, error), }; let mut committer = match automatic_committer(&repository, &request.entry) { Ok(committer) => committer, Err(error) => return operation_error(stderr, error), }; match writer.insert(request, contents, overwrite, None, &mut committer) { Ok(_) => Ok(EXIT_SUCCESS), Err(error) => operation_error(stderr, error), } } CommandRequest::Edit(request) => { let writer = VaultWriter::new(&repository, &keys); let session = match writer.begin_edit(request, secrets) { Ok(session) => session, Err(error) => return operation_error(stderr, error), }; let (replacement, editor_name) = match interaction.edit(session.plaintext(), config) { Ok(replacement) => replacement, Err(error) => return operation_error(stderr, error), }; let mut committer = match automatic_committer(&repository, &request.entry) { Ok(committer) => committer, Err(error) => return operation_error(stderr, error), }; match writer.finish_edit(session, replacement, &editor_name, None, &mut committer) { Ok(_) => Ok(EXIT_SUCCESS), Err(error) => operation_error(stderr, error), } } CommandRequest::Generate(request) => { let overwrite = if !request.force && !request.in_place && match VaultWriter::new(&repository, &keys).entry_exists(&request.entry) { Ok(exists) => exists, Err(error) => return operation_error(stderr, error), } { match interaction.confirm( &format!( "An entry already exists for {}. Overwrite it?", request.entry ), stderr, ) { Ok(decision) => decision, Err(error) => return operation_error(stderr, error), } } else { OverwriteDecision::Allow }; let mut committer = match automatic_committer(&repository, &request.entry) { Ok(committer) => committer, Err(error) => return operation_error(stderr, error), }; let outcome = match PasswordGenerator::new(&repository, &keys, GeneratorConfig::pass_defaults()) .generate(request, overwrite, None, secrets, &mut committer) { Ok(outcome) => outcome, Err(error) => return operation_error(stderr, error), }; let result = match request.presentation { GeneratedPresentation::Terminal => stdout .write_all(outcome.password().expose()) .and_then(|()| stdout.write_all(b"\n")) .map_err(|_| PresentationFailure::Output), GeneratedPresentation::Clipboard => presentation.clipboard( outcome.password(), config.clipboard_timeout(), &format!("generated password for {}", request.entry), stdout, ), GeneratedPresentation::QrCode => presentation.qr_code(outcome.password(), stdout), }; match result { Ok(()) => Ok(EXIT_SUCCESS), Err(error) => operation_error(stderr, error), } } CommandRequest::Remove(request) => { let decision = if request.force { OverwriteDecision::Allow } else { match interaction.confirm(&format!("Remove {}?", request.entry), stderr) { Ok(decision) => decision, Err(error) => return operation_error(stderr, error), } }; let mut committer = match automatic_committer(&repository, &request.entry) { Ok(committer) => committer, Err(error) => return operation_error(stderr, error), }; match TreeMutator::new(&repository, &keys).remove(request, decision, &mut committer) { Ok(_) => Ok(EXIT_SUCCESS), Err(error) => operation_error(stderr, error), } } CommandRequest::Move(request) => execute_transfer( &repository, &keys, TransferRequest::Move(request), secrets, interaction, stderr, ), CommandRequest::Copy(request) => execute_transfer( &repository, &keys, TransferRequest::Copy(request), secrets, interaction, stderr, ), CommandRequest::Otp(request) => execute_otp( config, request, secrets, presentation, interaction, clock, stdout, stderr, ), CommandRequest::Git(request) => { if let GitRequest::Diff { paths } = request { let git = match GitRepository::open(&repository, git_identity()) { Ok(git) => git, Err(error) => return operation_error(stderr, error), }; return match git.render_diff( &paths.iter().map(PathBuf::from).collect::>(), &keys, secrets, ) { Ok(diff) => { stdout.write_all(diff.expose()).map_err(|_| ())?; Ok(EXIT_SUCCESS) } Err(error) => operation_error(stderr, error), }; } let requested_remote = match request { GitRequest::Fetch { remote } | GitRequest::Pull { remote, .. } | GitRequest::Push { remote, .. } | GitRequest::Sync { remote } => remote.as_deref(), _ => unreachable!("local Git requests do not require secret access"), }; let configured = match select_remote(config, requested_remote) { Some(configured) => configured, None => { stderr .write_all(b"the requested HTTPS Git remote is not configured\n") .map_err(|_| ())?; return Ok(EXIT_CONFIG); } }; let git = match GitRepository::open(&repository, git_identity()) { Ok(git) => git, Err(error) => return operation_error(stderr, error), }; let result = match request { GitRequest::Fetch { .. } => git.fetch(configured, secrets).map(|_| None), GitRequest::Pull { branch, .. } => git .pull(configured, branch.as_deref(), secrets) .map(|outcome| Some(pull_outcome_name(outcome).to_owned())), GitRequest::Push { branch, .. } => git .push(configured, branch.as_deref(), secrets) .map(|outcome| Some(format!("{} {}", outcome.remote(), outcome.new_id()))), GitRequest::Sync { .. } => git.sync(configured, secrets).map(|(pull, push)| { Some(format!( "{}; {} {}", pull_outcome_name(pull), push.remote(), push.new_id() )) }), _ => unreachable!("local Git requests do not require secret access"), }; match result { Ok(Some(message)) => { writeln!(stdout, "{message}").map_err(|_| ())?; Ok(EXIT_SUCCESS) } Ok(None) => Ok(EXIT_SUCCESS), Err(error) => operation_error(stderr, error), } } CommandRequest::List(_) | CommandRequest::Find(_) | CommandRequest::Completion { .. } | CommandRequest::Help { .. } | CommandRequest::Version => unreachable!("request is handled before secure execution"), } } #[derive(Clone, Copy)] enum TransferRequest<'a> { Move(&'a ironstorage::command::MoveRequest), Copy(&'a ironstorage::command::CopyRequest), } impl TransferRequest<'_> { fn source(&self) -> &str { match self { Self::Move(request) => &request.source, Self::Copy(request) => &request.source, } } fn destination(&self) -> &str { match self { Self::Move(request) => &request.destination, Self::Copy(request) => &request.destination, } } fn force(&self) -> bool { match self { Self::Move(request) => request.force, Self::Copy(request) => request.force, } } } #[allow(clippy::too_many_arguments)] fn execute_transfer( repository: &Repository, keys: &KeyStore, request: TransferRequest<'_>, secrets: &mut SecretStore, interaction: &mut I, stderr: &mut E, ) -> Result { let mut committer = match automatic_committer(repository, request.source()) { Ok(committer) => committer, Err(error) => return operation_error(stderr, error), }; let mut decision = if request.force() { OverwriteDecision::Allow } else { OverwriteDecision::Decline }; loop { let mutator = TreeMutator::new(repository, keys); let result = match request { TransferRequest::Move(request) => { mutator.move_tree(request, decision, None, secrets, &mut committer) } TransferRequest::Copy(request) => { mutator.copy(request, decision, None, secrets, &mut committer) } }; match result { Ok(_) => return Ok(EXIT_SUCCESS), Err(MutationError::Cancelled) if decision == OverwriteDecision::Decline && !request.force() => { decision = match interaction.confirm( &format!( "An entry already exists at {}. Overwrite it?", request.destination() ), stderr, ) { Ok(decision) => decision, Err(error) => return operation_error(stderr, error), }; if decision == OverwriteDecision::Decline { return operation_error(stderr, MutationError::Cancelled); } } Err(error) => return operation_error(stderr, error), } } } fn pull_outcome_name(outcome: PullOutcome) -> &'static str { match outcome { PullOutcome::UpToDate => "up to date", PullOutcome::FastForward => "fast-forward", PullOutcome::Merged => "merged", } } #[allow(clippy::too_many_arguments)] fn execute_otp( config: &Config, request: &OtpRequest, secrets: &mut SecretStore, presentation: &mut P, interaction: &mut I, clock: impl Fn() -> Result, stdout: &mut O, stderr: &mut E, ) -> Result { let repository = match Repository::open(config.vault()) { Ok(repository) => repository, Err(error) => return operation_error(stderr, error), }; let keys = match KeyStore::load(config.key_material()) { Ok(keys) => keys, Err(error) => return operation_error(stderr, error), }; let service = OtpService::new(&repository, &keys); match request { OtpRequest::Code(request) => { let timestamp = match clock() { Ok(timestamp) => timestamp, Err(error) => return operation_error(stderr, error), }; let mut committer = match automatic_committer(&repository, request.entry.trim_end_matches('/')) { Ok(committer) => committer, Err(error) => return operation_error(stderr, error), }; let outcome = match service.code(&request.entry, timestamp, None, secrets, &mut committer) { Ok(outcome) => outcome, Err(error) => return operation_error(stderr, error), }; if request.clipboard { match presentation.clipboard( outcome.code(), config.clipboard_timeout(), &format!("OTP code for {}", request.entry), stdout, ) { Ok(()) => Ok(EXIT_SUCCESS), Err(error) => operation_error(stderr, error), } } else { stdout .write_all(outcome.code().expose()) .and_then(|()| stdout.write_all(b"\n")) .map_err(|_| ())?; Ok(EXIT_SUCCESS) } } OtpRequest::Insert(request) => { let input = match interaction.read_input( request.input_plan(interaction.standard_input_is_terminal()), &request.source, request.entry.as_deref().unwrap_or("this token"), stderr, ) { Ok(input) => input, Err(error) => return operation_error(stderr, error), }; let plan = match service.prepare_insert(request, input) { Ok(plan) => plan, Err(error) => return operation_error(stderr, error), }; let path_decision = if plan.requires_path_confirmation() { match interaction.confirm(&format!("Insert into {}?", plan.path()), stderr) { Ok(decision) => decision, Err(error) => return operation_error(stderr, error), } } else { OverwriteDecision::Allow }; if path_decision == OverwriteDecision::Decline { return operation_error(stderr, OtpError::Cancelled); } let overwrite = if plan.requires_overwrite_confirmation() { match interaction.confirm( &format!("An entry already exists for {}. Overwrite it?", plan.path()), stderr, ) { Ok(decision) => decision, Err(error) => return operation_error(stderr, error), } } else { OverwriteDecision::Allow }; let mut committer = match automatic_committer(&repository, &plan.path().to_string()) { Ok(committer) => committer, Err(error) => return operation_error(stderr, error), }; match service.finish_insert(plan, path_decision, overwrite, None, &mut committer) { Ok(_) => Ok(EXIT_SUCCESS), Err(error) => operation_error(stderr, error), } } OtpRequest::Append(request) => { let session = match service.begin_append(request, secrets) { Ok(session) => session, Err(error) => return operation_error(stderr, error), }; let replace = if session.requires_replace_confirmation() { match interaction.confirm( &format!( "An OTP secret already exists for {}. Overwrite it?", session.path() ), stderr, ) { Ok(decision) => decision, Err(error) => return operation_error(stderr, error), } } else { OverwriteDecision::Allow }; if replace == OverwriteDecision::Decline { return operation_error(stderr, OtpError::Cancelled); } let path = session.path().to_string(); let input = match interaction.read_input( request.input_plan(interaction.standard_input_is_terminal()), &request.source, &request.entry, stderr, ) { Ok(input) => input, Err(error) => return operation_error(stderr, error), }; let mut committer = match automatic_committer(&repository, &path) { Ok(committer) => committer, Err(error) => return operation_error(stderr, error), }; match service.finish_append(session, input, replace, None, &mut committer) { Ok(_) => Ok(EXIT_SUCCESS), Err(error) => operation_error(stderr, error), } } OtpRequest::Uri(request) => { let uri = match service.uri(&request.entry, secrets) { Ok(uri) => uri, Err(error) => return operation_error(stderr, error), }; let result = match request.presentation { OtpUriPresentation::Terminal => stdout .write_all(uri.encoded().expose()) .and_then(|()| stdout.write_all(b"\n")) .map_err(|_| PresentationFailure::Output), OtpUriPresentation::Clipboard => presentation.clipboard( uri.encoded(), config.clipboard_timeout(), &format!("OTP key URI for {}", request.entry), stdout, ), OtpUriPresentation::QrCode => presentation.qr_code(uri.encoded(), stdout), }; match result { Ok(()) => Ok(EXIT_SUCCESS), Err(error) => operation_error(stderr, error), } } OtpRequest::Validate { .. } | OtpRequest::Help | OtpRequest::Version => { Ok(EXIT_UNAVAILABLE) } } } trait OtpInteraction { fn standard_input_is_terminal(&self) -> bool; fn read_input( &mut self, plan: InputPlan, source: &OtpInputSource, prompt: &str, stderr: &mut dyn Write, ) -> Result; fn confirm( &mut self, prompt: &str, stderr: &mut dyn Write, ) -> Result; } trait CliInteraction: OtpInteraction { fn read_insert( &mut self, plan: InputPlan, entry: &str, stderr: &mut dyn Write, ) -> Result; fn edit( &mut self, plaintext: &SecretBytes, config: &Config, ) -> Result<(SecretBytes, String), CliInteractionError>; } struct NativeOtpInteraction; struct NativeOpenPgpPassphrasePrompt; impl OpenPgpPassphrasePrompt for NativeOpenPgpPassphrasePrompt { fn request_passphrase( &mut self, key: &ironstorage::crypto::KeyInfo, ) -> Result { if !std::io::stdin().is_terminal() { return Err(OpenPgpPassphrasePromptError::Unavailable); } rpassword::prompt_password(format!( "Enter OpenPGP passphrase for {}: ", key.fingerprint() )) .map(|value| SecretBytes::new(value.into_bytes())) .map_err(|error| match error.kind() { std::io::ErrorKind::Interrupted | std::io::ErrorKind::UnexpectedEof => { OpenPgpPassphrasePromptError::Cancelled } _ => OpenPgpPassphrasePromptError::Unavailable, }) } } impl OtpInteraction for NativeOtpInteraction { fn standard_input_is_terminal(&self) -> bool { std::io::stdin().is_terminal() } fn read_input( &mut self, plan: InputPlan, source: &OtpInputSource, prompt: &str, stderr: &mut dyn Write, ) -> Result { let subject = match source { OtpInputSource::Uri => "otpauth:// URI", OtpInputSource::Secret { .. } => "secret", }; match plan { InputPlan::HiddenConfirmed => { let first = rpassword::prompt_password(format!("Enter {subject} for {prompt}: ")) .map_err(|_| OtpInteractionError::Input)? .into_bytes(); let confirmation = rpassword::prompt_password(format!("Retype {subject} for {prompt}: ")) .map_err(|_| OtpInteractionError::Input)? .into_bytes(); OtpInput::hidden(first, confirmation).map_err(Into::into) } InputPlan::EchoedLine => { write!(stderr, "Enter {subject} for {prompt}: ") .and_then(|()| stderr.flush()) .map_err(|_| OtpInteractionError::Output)?; OtpInput::line(read_standard_input_line()?).map_err(Into::into) } InputPlan::StandardInputLine => { OtpInput::line(read_standard_input_line()?).map_err(Into::into) } InputPlan::StandardInputToEnd => Err(OtpInteractionError::Input), } } fn confirm( &mut self, prompt: &str, stderr: &mut dyn Write, ) -> Result { loop { write!(stderr, "{prompt} [y/N] ") .and_then(|()| stderr.flush()) .map_err(|_| OtpInteractionError::Output)?; let answer = read_standard_input_line()?; match answer.as_slice() { b"y" | b"Y" | b"yes" | b"YES" | b"Yes" => { return Ok(OverwriteDecision::Allow); } b"" | b"n" | b"N" | b"no" | b"NO" | b"No" => { return Ok(OverwriteDecision::Decline); } _ => {} } } } } impl CliInteraction for NativeOtpInteraction { fn read_insert( &mut self, plan: InputPlan, entry: &str, stderr: &mut dyn Write, ) -> Result { match plan { InputPlan::HiddenConfirmed => { let first = rpassword::prompt_password(format!("Enter password for {entry}: ")) .map_err(|_| CliInteractionError::Input)? .into_bytes(); let confirmation = rpassword::prompt_password(format!("Retype password for {entry}: ")) .map_err(|_| CliInteractionError::Input)? .into_bytes(); InsertContent::hidden(first, confirmation).map_err(Into::into) } InputPlan::EchoedLine => { write!(stderr, "Enter password for {entry}: ") .and_then(|()| stderr.flush()) .map_err(|_| CliInteractionError::Output)?; InsertContent::echoed(read_cli_input_line()?).map_err(Into::into) } InputPlan::StandardInputLine => { InsertContent::echoed(read_cli_input_line()?).map_err(Into::into) } InputPlan::StandardInputToEnd => { let mut contents = Vec::new(); std::io::stdin() .lock() .read_to_end(&mut contents) .map_err(|_| CliInteractionError::Input)?; Ok(InsertContent::multiline(contents)) } } } fn edit( &mut self, plaintext: &SecretBytes, config: &Config, ) -> Result<(SecretBytes, String), CliInteractionError> { let resolved = config .resolve_editor() .map_err(CliInteractionError::EditorResolution)?; let name = resolved.command().program().to_owned(); let replacement = editor::edit_replacement(plaintext, &resolved, &mut editor::NativeEditorHost) .map_err(CliInteractionError::Editor)?; Ok((replacement, name)) } } #[cfg(test)] struct UnavailableOtpInteraction; #[cfg(test)] impl OtpInteraction for UnavailableOtpInteraction { fn standard_input_is_terminal(&self) -> bool { false } fn read_input( &mut self, _plan: InputPlan, _source: &OtpInputSource, _prompt: &str, _stderr: &mut dyn Write, ) -> Result { Err(OtpInteractionError::Input) } fn confirm( &mut self, _prompt: &str, _stderr: &mut dyn Write, ) -> Result { Err(OtpInteractionError::Input) } } #[cfg(test)] impl CliInteraction for UnavailableOtpInteraction { fn read_insert( &mut self, _plan: InputPlan, _entry: &str, _stderr: &mut dyn Write, ) -> Result { Err(CliInteractionError::Input) } fn edit( &mut self, _plaintext: &SecretBytes, _config: &Config, ) -> Result<(SecretBytes, String), CliInteractionError> { Err(CliInteractionError::Input) } } #[derive(Debug)] enum CliInteractionError { Write(ironstorage::write::WriteError), EditorResolution(ironstorage::config::EditorError), Editor(editor::CliEditorError), Input, Output, } impl fmt::Display for CliInteractionError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Write(error) => error.fmt(formatter), Self::EditorResolution(error) => error.fmt(formatter), Self::Editor(error) => error.fmt(formatter), Self::Input => formatter.write_str("command input could not be read"), Self::Output => formatter.write_str("command prompt could not be written"), } } } impl From for CliInteractionError { fn from(error: ironstorage::write::WriteError) -> Self { Self::Write(error) } } impl Error for CliInteractionError {} #[derive(Debug)] enum OtpInteractionError { Otp(OtpError), Input, Output, Clock, } impl fmt::Display for OtpInteractionError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Otp(error) => error.fmt(formatter), Self::Input => formatter.write_str("OTP input could not be read"), Self::Output => formatter.write_str("OTP prompt could not be written"), Self::Clock => formatter.write_str("the system clock is before the Unix epoch"), } } } impl From for OtpInteractionError { fn from(error: OtpError) -> Self { Self::Otp(error) } } impl Error for OtpInteractionError {} fn read_standard_input_line() -> Result, OtpInteractionError> { let mut value = Vec::new(); let read = std::io::stdin() .lock() .read_until(b'\n', &mut value) .map_err(|_| OtpInteractionError::Input)?; if read == 0 { return Err(OtpInteractionError::Input); } if value.ends_with(b"\n") { value.pop(); } if value.ends_with(b"\r") { value.pop(); } Ok(value) } fn read_cli_input_line() -> Result, CliInteractionError> { let mut value = Vec::new(); let read = std::io::stdin() .lock() .read_until(b'\n', &mut value) .map_err(|_| CliInteractionError::Input)?; if read == 0 { return Err(CliInteractionError::Input); } if value.ends_with(b"\n") { value.pop(); } if value.ends_with(b"\r") { value.pop(); } Ok(value) } fn current_unix_seconds() -> Result { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_secs()) .map_err(|_| OtpInteractionError::Clock) } enum AutomaticCommitter { Git(Box), None { entry: NoGitEntryCommitter, policy: NoGitCommitter, tree: NoGitTreeCommitter, }, } impl EntryCommitter for AutomaticCommitter { fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> { match self { Self::Git(git) => EntryCommitter::commit(git.as_mut(), change), Self::None { entry, .. } => entry.commit(change), } } } impl PolicyCommitter for AutomaticCommitter { fn commit(&mut self, change: &PolicyCommit) -> Result<(), PolicyCommitError> { match self { Self::Git(git) => PolicyCommitter::commit(git.as_mut(), change), Self::None { policy, .. } => policy.commit(change), } } } impl TreeCommitter for AutomaticCommitter { fn commit(&mut self, change: &TreeCommit) -> Result<(), TreeCommitError> { match self { Self::Git(git) => TreeCommitter::commit(git.as_mut(), change), Self::None { tree, .. } => tree.commit(change), } } } fn automatic_committer( repository: &Repository, entry: &str, ) -> Result { match GitRepository::open_innermost(repository, Path::new(entry), git_identity()) { Ok(git) => Ok(AutomaticCommitter::Git(Box::new(git))), Err(ironstorage::git::GitError::NotRepository) => Ok(AutomaticCommitter::None { entry: NoGitEntryCommitter, policy: NoGitCommitter, tree: NoGitTreeCommitter, }), Err(error) => Err(error), } } trait CliPresentation { fn clipboard( &mut self, value: &SecretBytes, timeout: ClipboardTimeout, description: &str, stdout: &mut dyn Write, ) -> Result<(), PresentationFailure>; fn qr_code( &mut self, value: &SecretBytes, stdout: &mut dyn Write, ) -> Result<(), PresentationFailure>; } struct NativePresentation; impl CliPresentation for NativePresentation { fn clipboard( &mut self, value: &SecretBytes, timeout: ClipboardTimeout, description: &str, stdout: &mut dyn Write, ) -> Result<(), PresentationFailure> { let cancelled = Arc::new(AtomicBool::new(false)); let signal = Arc::clone(&cancelled); ctrlc::set_handler(move || signal.store(true, Ordering::SeqCst)) .map_err(|_| PresentationFailure::CancellationHandler)?; let mut clipboard = NativeClipboardManager::system(timeout)?; let mut output_failed = false; let result = clipboard.copy_with(value, |duration| { if writeln!( stdout, "Copied {description} to clipboard. Will restore or clear in {} seconds.", duration.as_secs() ) .and_then(|()| stdout.flush()) .is_err() { output_failed = true; return ClipboardWait::Cancelled; } wait_for_clipboard(duration, &cancelled) }); if output_failed { return Err(PresentationFailure::Output); } result.map(|_| ()).map_err(Into::into) } fn qr_code( &mut self, value: &SecretBytes, stdout: &mut dyn Write, ) -> Result<(), PresentationFailure> { let matrix = QrMatrix::encode(value)?; let rendered = matrix.render_terminal(); stdout .write_all(rendered.expose()) .map_err(|_| PresentationFailure::Output) } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum PresentationFailure { Clipboard(ClipboardError), Qr(QrError), CancellationHandler, Output, } impl fmt::Display for PresentationFailure { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Clipboard(error) => error.fmt(formatter), Self::Qr(error) => error.fmt(formatter), Self::CancellationHandler => { formatter.write_str("clipboard cancellation handling is unavailable") } Self::Output => formatter.write_str("presentation output could not be written"), } } } fn wait_for_clipboard(duration: Duration, cancelled: &AtomicBool) -> ClipboardWait { let deadline = Instant::now() + duration; loop { if cancelled.load(Ordering::SeqCst) { return ClipboardWait::Cancelled; } let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { return ClipboardWait::Elapsed; } std::thread::sleep(remaining.min(Duration::from_millis(50))); } } impl Error for PresentationFailure {} impl From for PresentationFailure { fn from(error: ClipboardError) -> Self { Self::Clipboard(error) } } impl From for PresentationFailure { fn from(error: QrError) -> Self { Self::Qr(error) } } fn select_remote<'a>( config: &'a Config, requested: Option<&str>, ) -> Option<&'a ironstorage::config::GitRemote> { match requested { Some(requested) => config .git_remotes() .iter() .find(|remote| remote.name().as_str() == requested), None => config.git_remotes().first(), } } fn operation_error(stderr: &mut E, error: impl std::fmt::Display) -> Result { writeln!(stderr, "{error}").map_err(|_| ())?; Ok(EXIT_FAILURE) } #[cfg(test)] mod tests { use std::{ collections::{BTreeMap, VecDeque}, error::Error, ffi::OsString, fs, sync::{Arc, Mutex}, }; use ironstorage::{ command::{ CommandRequest, CopyRequest, EXIT_CONFIG, EXIT_FAILURE, EXIT_SUCCESS, EXIT_UNAVAILABLE, EXIT_USAGE, EditRequest, FindRequest, GenerateRequest, GeneratedPresentation, GitRequest, GrepRequest, InitRequest, InputPlan, InsertInput, InsertRequest, ListRequest, MoveRequest, OtpAppendRequest, OtpCodeRequest, OtpInputSource, OtpInsertRequest, OtpRequest, OtpUriPresentation, OtpUriRequest, Presentation, RemoveRequest, ShowRequest, }, config::Config, crypto::KeyInfo, git::GitCredentialProvider as _, otp::OtpInput, presentation::{ClipboardTimeout, QrMatrix}, repository::{EntryPath, Repository, SecretBytes}, secret_store::{ OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError, SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy, SecretReference, SecretStore, SecretStoreBackend, SecretStoreError, }, write::{InsertContent, OverwriteDecision}, }; use super::{ CliPresentation, OtpInteraction, OtpInteractionError, PresentationFailure, execute_local, execute_secure, execute_secure_with, execute_secure_with_services, run_with, wait_for_clipboard, }; type TestResult = Result<(), Box>; #[derive(Clone, Default)] struct MemoryBackend(Arc>>); #[derive(Clone, Default)] struct MemoryOpenPgpPrompt(Arc>); #[derive(Default)] struct MemoryOpenPgpPromptState { responses: VecDeque, OpenPgpPassphrasePromptError>>, requests: Vec, } impl MemoryOpenPgpPrompt { fn with_passphrase(passphrase: &[u8]) -> Self { let prompt = Self::default(); prompt .0 .lock() .expect("test mutex") .responses .push_back(Ok(passphrase.to_vec())); prompt } fn requests(&self) -> Vec { self.0.lock().expect("test mutex").requests.clone() } } impl OpenPgpPassphrasePrompt for MemoryOpenPgpPrompt { fn request_passphrase( &mut self, key: &KeyInfo, ) -> Result { let mut state = self.0.lock().expect("test mutex"); state.requests.push(key.fingerprint().as_str().to_owned()); state .responses .pop_front() .unwrap_or(Err(OpenPgpPassphrasePromptError::Unavailable)) .map(SecretBytes::new) } } #[derive(Default)] struct MemoryPresentation { clipboard: Vec>, qr: Vec>, } #[derive(Default)] struct MemoryOtpInteraction { terminal: bool, inputs: VecDeque, insert_inputs: VecDeque, edit_replacements: VecDeque, decisions: VecDeque, plans: Vec, prompts: Vec, } impl OtpInteraction for MemoryOtpInteraction { fn standard_input_is_terminal(&self) -> bool { self.terminal } fn read_input( &mut self, plan: InputPlan, _source: &OtpInputSource, prompt: &str, _stderr: &mut dyn std::io::Write, ) -> Result { self.plans.push(plan); self.prompts.push(prompt.to_owned()); self.inputs.pop_front().ok_or(OtpInteractionError::Input) } fn confirm( &mut self, prompt: &str, _stderr: &mut dyn std::io::Write, ) -> Result { self.prompts.push(prompt.to_owned()); self.decisions.pop_front().ok_or(OtpInteractionError::Input) } } impl super::CliInteraction for MemoryOtpInteraction { fn read_insert( &mut self, _plan: InputPlan, _entry: &str, _stderr: &mut dyn std::io::Write, ) -> Result { self.insert_inputs .pop_front() .ok_or(super::CliInteractionError::Input) } fn edit( &mut self, _plaintext: &SecretBytes, _config: &Config, ) -> Result<(SecretBytes, String), super::CliInteractionError> { self.edit_replacements .pop_front() .map(|replacement| (replacement, "fixture-editor".to_owned())) .ok_or(super::CliInteractionError::Input) } } impl CliPresentation for MemoryPresentation { fn clipboard( &mut self, value: &SecretBytes, timeout: ClipboardTimeout, description: &str, stdout: &mut dyn std::io::Write, ) -> Result<(), PresentationFailure> { self.clipboard.push(value.expose().to_vec()); writeln!( stdout, "Copied {description} for {} seconds.", timeout.duration().as_secs() ) .map_err(|_| PresentationFailure::Output) } fn qr_code( &mut self, value: &SecretBytes, stdout: &mut dyn std::io::Write, ) -> Result<(), PresentationFailure> { self.qr.push(value.expose().to_vec()); let matrix = QrMatrix::encode(value)?; stdout .write_all(matrix.render_terminal().expose()) .map_err(|_| PresentationFailure::Output) } } impl SecretStoreBackend for MemoryBackend { fn create( &self, locator: &SecretLocator, _protection: SecretProtection, value: &[u8], ) -> Result<(), SecretStoreError> { let mut values = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; if values.contains_key(locator) { return Err(SecretStoreError::AlreadyExists); } values.insert(locator.clone(), SecretBytes::new(value.to_vec())); Ok(()) } fn retrieve( &self, locator: &SecretLocator, _protection: SecretProtection, ) -> Result { self.0 .lock() .map_err(|_| SecretStoreError::Unavailable)? .get(locator) .map(|value| SecretBytes::new(value.expose().to_vec())) .ok_or(SecretStoreError::Missing) } fn replace( &self, locator: &SecretLocator, _protection: SecretProtection, value: &[u8], ) -> Result<(), SecretStoreError> { let mut values = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; let existing = values.get_mut(locator).ok_or(SecretStoreError::Missing)?; *existing = SecretBytes::new(value.to_vec()); Ok(()) } fn delete( &self, locator: &SecretLocator, _protection: SecretProtection, ) -> Result<(), SecretStoreError> { self.0 .lock() .map_err(|_| SecretStoreError::Unavailable)? .remove(locator) .map(drop) .ok_or(SecretStoreError::Missing) } } #[test] fn clipboard_wait_is_interruptible_without_skipping_storage_cleanup() { let cancelled = std::sync::atomic::AtomicBool::new(true); assert_eq!( wait_for_clipboard(std::time::Duration::from_secs(60), &cancelled), ironstorage::presentation::ClipboardWait::Cancelled ); let elapsed = std::sync::atomic::AtomicBool::new(false); assert_eq!( wait_for_clipboard(std::time::Duration::from_millis(1), &elapsed), ironstorage::presentation::ClipboardWait::Elapsed ); } #[test] fn help_and_usage_errors_have_stable_streams_and_exit_codes() -> TestResult { let mut stdout = Vec::new(); let mut stderr = Vec::new(); let code = run_with(["ironstorage", "--help"], &mut stdout, &mut stderr) .expect("writing to memory cannot fail"); assert_eq!(code, EXIT_SUCCESS); assert!(String::from_utf8(stdout)?.contains("Usage")); assert!(stderr.is_empty()); let mut stdout = Vec::new(); let mut stderr = Vec::new(); let code = run_with( ["ironstorage", "insert", "--echo", "--multiline", "entry"], &mut stdout, &mut stderr, ) .expect("writing to memory cannot fail"); assert_eq!(code, EXIT_USAGE); assert!(stdout.is_empty()); assert!(String::from_utf8(stderr)?.contains("cannot be used with")); Ok(()) } #[test] fn configuration_and_unavailable_operations_have_stable_exit_codes() -> TestResult { let temporary = tempfile::tempdir()?; let missing = temporary.path().join("missing.toml"); let mut stdout = Vec::new(); let mut stderr = Vec::new(); let code = run_with( [ OsString::from("ironstorage"), OsString::from("--config"), missing.into_os_string(), OsString::from("show"), OsString::from("entry"), ], &mut stdout, &mut stderr, ) .expect("writing to memory cannot fail"); assert_eq!(code, EXIT_CONFIG); assert!(stdout.is_empty()); assert!(String::from_utf8(stderr)?.contains("configuration file not found")); let keys = temporary.path().join("keys"); fs::create_dir(&keys)?; let config = temporary.path().join("config.toml"); fs::write( &config, "vault = 'vault'\ndefault_key = 'alice'\nkey_material = 'keys'\n", )?; let mut stdout = Vec::new(); let mut stderr = Vec::new(); let code = run_with( [ OsString::from("ironstorage"), OsString::from("--config"), config.into_os_string(), OsString::from("show"), OsString::from("entry"), ], &mut stdout, &mut stderr, ) .expect("writing to memory cannot fail"); assert_eq!(code, EXIT_UNAVAILABLE); assert!(stdout.is_empty()); assert!(String::from_utf8(stderr)?.contains("secret store is unavailable")); Ok(()) } #[test] fn cli_session_unlocks_protected_entries_and_https_credentials_by_reference() -> TestResult { const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30"; let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../crates/storage/tests/fixtures/compatibility"); let temporary = tempfile::tempdir()?; let config_path = temporary.path().join("config.toml"); fs::write( &config_path, format!( "vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\n[[git.remotes]]\nname = 'origin'\nurl = 'https://example.test/store.git'\nserver_id = 'fixture-server'\napplication_id = 'fixture-app'\n", fixtures.join("stores/basic"), FINGERPRINT, fixtures.join("keys"), ), )?; let config = Config::load(Some(&config_path))?; let mut secrets = SecretStore::new( MemoryBackend::default(), SecretCachePolicy::Disabled, SecretProtectionPolicy::device_unlocked(), ); secrets.unlock()?; secrets.create( &SecretReference::openpgp_passphrase(FINGERPRINT)?, SecretBytes::new(b"fixture-alice-passphrase".to_vec()), )?; let mut stdout = Vec::new(); let mut stderr = Vec::new(); assert_eq!( execute_secure( &config, &CommandRequest::Show(ShowRequest { entry: Some("email/personal".to_owned()), presentation: Presentation::Terminal, }), &mut secrets, &mut stdout, &mut stderr, ) .expect("memory output cannot fail"), EXIT_SUCCESS ); assert_eq!( stdout, fs::read(fixtures.join("expected/basic/email/personal.txt"))? ); assert!(stderr.is_empty()); let remote = &config.git_remotes()[0]; secrets.create( &SecretReference::https_git_credential( remote.server_id().as_str(), remote.application_id().as_str(), "fixture-account", )?, SecretBytes::new(b"fixture-token".to_vec()), )?; let credential = secrets.credential(remote.server_id(), remote.application_id())?; assert_eq!(credential.username(), "fixture-account"); assert_eq!(credential.password(), b"fixture-token"); Ok(()) } #[test] fn cli_prompts_for_a_missing_openpgp_passphrase_then_stores_and_reuses_it() -> TestResult { const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30"; const PASSPHRASE: &[u8] = b"fixture-alice-passphrase"; let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../crates/storage/tests/fixtures/compatibility"); let temporary = tempfile::tempdir()?; let config_path = temporary.path().join("config.toml"); fs::write( &config_path, format!( "vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\n", fixtures.join("stores/basic"), FINGERPRINT, fixtures.join("keys"), ), )?; let config = Config::load(Some(&config_path))?; let prompt = MemoryOpenPgpPrompt::with_passphrase(PASSPHRASE); let mut secrets = SecretStore::new( MemoryBackend::default(), SecretCachePolicy::Disabled, SecretProtectionPolicy::device_unlocked(), ) .with_openpgp_passphrase_prompt(prompt.clone()); secrets.unlock()?; let request = CommandRequest::Show(ShowRequest { entry: Some("email/personal".to_owned()), presentation: Presentation::Terminal, }); for _ in 0..2 { let mut stdout = Vec::new(); let mut stderr = Vec::new(); assert_eq!( execute_secure(&config, &request, &mut secrets, &mut stdout, &mut stderr) .expect("memory output cannot fail"), EXIT_SUCCESS ); assert_eq!( stdout, fs::read(fixtures.join("expected/basic/email/personal.txt"))? ); assert!(stderr.is_empty()); } assert_eq!(prompt.requests(), [FINGERPRINT.to_owned()]); assert_eq!( secrets .retrieve(&SecretReference::openpgp_passphrase(FINGERPRINT)?)? .expose(), PASSPHRASE ); Ok(()) } #[test] fn cli_clipboard_and_qr_paths_never_emit_plaintext() -> TestResult { const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30"; let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../crates/storage/tests/fixtures/compatibility"); let temporary = tempfile::tempdir()?; let config_path = temporary.path().join("config.toml"); fs::write( &config_path, format!( "vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\nclipboard_timeout_seconds = 1\n", fixtures.join("stores/basic"), FINGERPRINT, fixtures.join("keys"), ), )?; let config = Config::load(Some(&config_path))?; let mut secrets = fixture_secrets(FINGERPRINT)?; let mut presentation = MemoryPresentation::default(); for channel in [ Presentation::Clipboard { line: std::num::NonZeroUsize::new(1).expect("non-zero"), }, Presentation::QrCode { line: std::num::NonZeroUsize::new(1).expect("non-zero"), }, ] { let mut stdout = Vec::new(); let mut stderr = Vec::new(); assert_eq!( execute_secure_with( &config, &CommandRequest::Show(ShowRequest { entry: Some("email/personal".to_owned()), presentation: channel, }), &mut secrets, &mut presentation, &mut stdout, &mut stderr, ) .expect("memory output cannot fail"), EXIT_SUCCESS ); assert!( !stdout .windows(b"correct horse fixture".len()) .any(|part| { part == b"correct horse fixture" }) ); assert!(stderr.is_empty()); } assert_eq!( presentation.clipboard, vec![b"correct horse fixture".to_vec()] ); assert_eq!(presentation.qr, vec![b"correct horse fixture".to_vec()]); Ok(()) } #[test] fn cli_generated_passwords_use_clipboard_and_qr_without_plaintext_output() -> TestResult { const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30"; let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../crates/storage/tests/fixtures/compatibility"); let temporary = tempfile::tempdir()?; let vault = temporary.path().join("vault"); fs::create_dir(&vault)?; fs::copy(fixtures.join("stores/basic/.gpg-id"), vault.join(".gpg-id"))?; fs::copy( fixtures.join("stores/basic/.gpg-id.sig"), vault.join(".gpg-id.sig"), )?; let config_path = temporary.path().join("config.toml"); fs::write( &config_path, format!( "vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\nclipboard_timeout_seconds = 1\n", vault, FINGERPRINT, fixtures.join("keys"), ), )?; let config = Config::load(Some(&config_path))?; let mut secrets = fixture_secrets(FINGERPRINT)?; let mut presentation = MemoryPresentation::default(); for (entry, channel) in [ ("generated/clip", GeneratedPresentation::Clipboard), ("generated/qr", GeneratedPresentation::QrCode), ] { let mut stdout = Vec::new(); let mut stderr = Vec::new(); assert_eq!( execute_secure_with( &config, &CommandRequest::Generate(GenerateRequest { entry: entry.to_owned(), length: std::num::NonZeroUsize::new(16), no_symbols: true, force: false, in_place: false, presentation: channel, }), &mut secrets, &mut presentation, &mut stdout, &mut stderr, ) .expect("memory output cannot fail"), EXIT_SUCCESS ); let presented = match channel { GeneratedPresentation::Clipboard => presentation.clipboard.last(), GeneratedPresentation::QrCode => presentation.qr.last(), GeneratedPresentation::Terminal => None, } .expect("generated secret was presented"); assert_eq!(presented.len(), 16); assert!(presented.iter().all(u8::is_ascii_alphanumeric)); assert!( !stdout .windows(presented.len()) .any(|part| part == presented) ); assert!(stderr.is_empty()); assert!(vault.join(format!("{entry}.gpg")).is_file()); } Ok(()) } #[test] fn otp_validate_runs_without_configuration_and_redacts_invalid_input() -> TestResult { let valid = "otpauth://totp/account?secret=JBSWY3DPEHPK3PXP"; let mut stdout = Vec::new(); let mut stderr = Vec::new(); assert_eq!( run_with( ["ironstorage", "otp", "validate", valid], &mut stdout, &mut stderr, ) .expect("memory output cannot fail"), EXIT_SUCCESS ); assert!(stdout.is_empty()); assert!(stderr.is_empty()); let invalid = "otpauth://totp/account?secret=PRIVATE-NOT-BASE32"; assert_eq!( run_with( ["ironstorage", "otp", "validate", invalid], &mut stdout, &mut stderr, ) .expect("memory output cannot fail"), EXIT_FAILURE ); assert!( !stderr .windows(invalid.len()) .any(|part| part == invalid.as_bytes()) ); Ok(()) } #[test] fn cli_otp_code_and_uri_use_secret_safe_presentation_channels() -> TestResult { const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30"; let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../crates/storage/tests/fixtures/compatibility"); let temporary = tempfile::tempdir()?; let config_path = temporary.path().join("config.toml"); fs::write( &config_path, format!( "vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\nclipboard_timeout_seconds = 1\n", fixtures.join("stores/basic"), FINGERPRINT, fixtures.join("keys"), ), )?; let config = Config::load(Some(&config_path))?; let mut secrets = fixture_secrets(FINGERPRINT)?; let mut presentation = MemoryPresentation::default(); let mut interaction = MemoryOtpInteraction::default(); let mut stdout = Vec::new(); let mut stderr = Vec::new(); assert_eq!( execute_secure_with_services( &config, &CommandRequest::Otp(OtpRequest::Code(OtpCodeRequest { entry: "otp/totp".to_owned(), clipboard: true, })), &mut secrets, &mut presentation, &mut interaction, || Ok(59), &mut stdout, &mut stderr, ) .expect("memory output cannot fail"), EXIT_SUCCESS ); let code = presentation.clipboard.last().expect("clipboard code"); assert_eq!(code.len(), 6); assert!(code.iter().all(u8::is_ascii_digit)); assert!(!stdout.windows(code.len()).any(|part| part == code)); assert!(stderr.is_empty()); stdout.clear(); let uri = fs::read(fixtures.join("expected/basic/otp/totp.txt"))?; let uri = uri .split(|byte| *byte == b'\n') .find(|line| line.starts_with(b"otpauth://")) .expect("fixture URI"); assert_eq!( execute_secure_with_services( &config, &CommandRequest::Otp(OtpRequest::Uri(OtpUriRequest { entry: "otp/totp".to_owned(), presentation: OtpUriPresentation::QrCode, })), &mut secrets, &mut presentation, &mut interaction, || Ok(59), &mut stdout, &mut stderr, ) .expect("memory output cannot fail"), EXIT_SUCCESS ); assert_eq!(presentation.qr.last().map(Vec::as_slice), Some(uri)); assert!(!stdout.windows(uri.len()).any(|part| part == uri)); assert!(stderr.is_empty()); stdout.clear(); assert_eq!( execute_secure_with_services( &config, &CommandRequest::Otp(OtpRequest::Uri(OtpUriRequest { entry: "otp/totp".to_owned(), presentation: OtpUriPresentation::Clipboard, })), &mut secrets, &mut presentation, &mut interaction, || Ok(59), &mut stdout, &mut stderr, ) .expect("memory output cannot fail"), EXIT_SUCCESS ); assert_eq!(presentation.clipboard.last().map(Vec::as_slice), Some(uri)); assert!(!stdout.windows(uri.len()).any(|part| part == uri)); assert!(stderr.is_empty()); Ok(()) } #[test] fn cli_otp_insert_append_and_hotp_increment_mutate_encrypted_entries() -> TestResult { const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30"; let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../crates/storage/tests/fixtures/compatibility"); let temporary = tempfile::tempdir()?; let vault = temporary.path().join("vault"); fs::create_dir_all(vault.join("email"))?; fs::create_dir_all(vault.join("otp"))?; for path in [ ".gpg-id", ".gpg-id.sig", "email/personal.gpg", "otp/hotp.gpg", ] { fs::copy(fixtures.join("stores/basic").join(path), vault.join(path))?; } let config_path = temporary.path().join("config.toml"); fs::write( &config_path, format!( "vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\nclipboard_timeout_seconds = 1\n", vault, FINGERPRINT, fixtures.join("keys"), ), )?; let config = Config::load(Some(&config_path))?; let mut secrets = fixture_secrets(FINGERPRINT)?; let mut presentation = MemoryPresentation::default(); let mut interaction = MemoryOtpInteraction { terminal: true, ..MemoryOtpInteraction::default() }; interaction.inputs.push_back(OtpInput::hidden( b"JBSWY3DPEHPK3PXP".to_vec(), b"JBSWY3DPEHPK3PXP".to_vec(), )?); interaction.decisions.push_back(OverwriteDecision::Allow); let mut stdout = Vec::new(); let mut stderr = Vec::new(); assert_eq!( execute_secure_with_services( &config, &CommandRequest::Otp(OtpRequest::Insert(OtpInsertRequest { entry: None, force: false, echo: false, source: OtpInputSource::Secret { issuer: Some("Issuer".to_owned()), account: Some("account".to_owned()), }, })), &mut secrets, &mut presentation, &mut interaction, || Ok(0), &mut stdout, &mut stderr, ) .expect("memory output cannot fail"), EXIT_SUCCESS ); assert_eq!(interaction.plans, vec![InputPlan::HiddenConfirmed]); assert!(vault.join("Issuer/account.gpg").is_file()); interaction.terminal = false; interaction.inputs.push_back(OtpInput::line( b"otpauth://totp/New:alice?secret=JBSWY3DPEHPK3PXP&issuer=New".to_vec(), )?); assert_eq!( execute_secure_with_services( &config, &CommandRequest::Otp(OtpRequest::Append(OtpAppendRequest { entry: "email/personal".to_owned(), force: false, echo: false, source: OtpInputSource::Uri, })), &mut secrets, &mut presentation, &mut interaction, || Ok(0), &mut stdout, &mut stderr, ) .expect("memory output cannot fail"), EXIT_SUCCESS ); assert_eq!( interaction.plans.last(), Some(&InputPlan::StandardInputLine) ); stdout.clear(); assert_eq!( execute_secure_with_services( &config, &CommandRequest::Otp(OtpRequest::Code(OtpCodeRequest { entry: "otp/hotp".to_owned(), clipboard: false, })), &mut secrets, &mut presentation, &mut interaction, || Ok(0), &mut stdout, &mut stderr, ) .expect("memory output cannot fail"), EXIT_SUCCESS ); assert_eq!(stdout.len(), 9); assert!(stdout[..8].iter().all(u8::is_ascii_digit)); let repository = Repository::open(&vault)?; let keys = ironstorage::crypto::KeyStore::load(fixtures.join("keys"))?; let derived = keys.decrypt( &repository.read_entry(&EntryPath::parse("Issuer/account")?)?, &mut secrets, )?; assert_eq!( derived.expose(), b"otpauth://totp/Issuer:account?secret=JBSWY3DPEHPK3PXP&issuer=Issuer\n" ); let appended = keys.decrypt( &repository.read_entry(&EntryPath::parse("email/personal")?)?, &mut secrets, )?; assert!( appended .expose() .windows(14) .any(|part| part == b"otpauth://totp") ); let hotp = keys.decrypt( &repository.read_entry(&EntryPath::parse("otp/hotp")?)?, &mut secrets, )?; assert!(hotp.expose().windows(9).any(|part| part == b"counter=1")); assert!(stderr.is_empty()); Ok(()) } #[test] fn complete_cli_dispatch_runs_storage_workflows_and_automatic_commits() -> TestResult { const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30"; let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../crates/storage/tests/fixtures/compatibility"); let temporary = tempfile::tempdir()?; let vault = temporary.path().join("vault"); fs::create_dir_all(vault.join("email"))?; for path in [".gpg-id", ".gpg-id.sig", "email/personal.gpg"] { fs::copy(fixtures.join("stores/basic").join(path), vault.join(path))?; } let config_path = temporary.path().join("config.toml"); fs::write( &config_path, format!( "vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\n", vault, FINGERPRINT, fixtures.join("keys"), ), )?; let config = Config::load(Some(&config_path))?; let mut stdout = Vec::new(); let mut stderr = Vec::new(); assert_eq!( execute_local( &config, &CommandRequest::Git(GitRequest::Init), &mut stdout, &mut stderr, ) .expect("memory output cannot fail"), EXIT_SUCCESS ); stdout.clear(); assert_eq!( execute_local( &config, &CommandRequest::List(ListRequest { path: None }), &mut stdout, &mut stderr, ) .expect("memory output cannot fail"), EXIT_SUCCESS ); assert!(stdout.windows(5).any(|part| part == b"email")); stdout.clear(); assert_eq!( execute_local( &config, &CommandRequest::Find(FindRequest { terms: vec!["PERSONAL".to_owned()], }), &mut stdout, &mut stderr, ) .expect("memory output cannot fail"), EXIT_SUCCESS ); assert!(stdout.windows(8).any(|part| part == b"personal")); let mut secrets = fixture_secrets(FINGERPRINT)?; let mut presentation = MemoryPresentation::default(); let mut interaction = MemoryOtpInteraction::default(); stdout.clear(); assert_eq!( execute_secure_with_services( &config, &CommandRequest::Grep(GrepRequest { pattern: "alice@example\\.test".to_owned(), ignore_case: false, invert_match: false, line_number: false, fixed_strings: false, }), &mut secrets, &mut presentation, &mut interaction, || Ok(0), &mut stdout, &mut stderr, ) .expect("memory output cannot fail"), EXIT_SUCCESS ); assert!(stdout.windows(14).any(|part| part == b"email/personal")); assert_eq!( execute_secure_with_services( &config, &CommandRequest::Init(InitRequest { path: Some("nested".to_owned()), key_identities: vec![FINGERPRINT.to_owned()], }), &mut secrets, &mut presentation, &mut interaction, || Ok(0), &mut stdout, &mut stderr, ) .expect("memory output cannot fail"), EXIT_SUCCESS ); interaction .insert_inputs .push_back(InsertContent::echoed(b"first secret".to_vec())?); assert_eq!( execute_secure_with_services( &config, &CommandRequest::Insert(InsertRequest { entry: "nested/new".to_owned(), input: InsertInput::EchoedLine, force: false, }), &mut secrets, &mut presentation, &mut interaction, || Ok(0), &mut stdout, &mut stderr, ) .expect("memory output cannot fail"), EXIT_SUCCESS ); interaction .edit_replacements .push_back(SecretBytes::new(b"edited secret\nmetadata".to_vec())); assert_eq!( execute_secure_with_services( &config, &CommandRequest::Edit(EditRequest { entry: "nested/new".to_owned(), }), &mut secrets, &mut presentation, &mut interaction, || Ok(0), &mut stdout, &mut stderr, ) .expect("memory output cannot fail"), EXIT_SUCCESS ); for request in [ CommandRequest::Copy(CopyRequest { source: "nested/new".to_owned(), destination: "nested/copied".to_owned(), force: false, }), CommandRequest::Move(MoveRequest { source: "nested/copied".to_owned(), destination: "nested/moved".to_owned(), force: false, }), ] { assert_eq!( execute_secure_with_services( &config, &request, &mut secrets, &mut presentation, &mut interaction, || Ok(0), &mut stdout, &mut stderr, ) .expect("memory output cannot fail"), EXIT_SUCCESS ); } interaction.decisions.push_back(OverwriteDecision::Allow); assert_eq!( execute_secure_with_services( &config, &CommandRequest::Remove(RemoveRequest { entry: "nested/moved".to_owned(), recursive: false, force: false, }), &mut secrets, &mut presentation, &mut interaction, || Ok(0), &mut stdout, &mut stderr, ) .expect("memory output cannot fail"), EXIT_SUCCESS ); let repository = Repository::open(&vault)?; let keys = ironstorage::crypto::KeyStore::load(fixtures.join("keys"))?; let edited = keys.decrypt( &repository.read_entry(&EntryPath::parse("nested/new")?)?, &mut secrets, )?; assert_eq!(edited.expose(), b"edited secret\nmetadata"); assert!(!vault.join("nested/moved.gpg").exists()); stdout.clear(); assert_eq!( execute_local( &config, &CommandRequest::Git(GitRequest::Status), &mut stdout, &mut stderr, ) .expect("memory output cannot fail"), EXIT_SUCCESS ); assert_eq!(stdout, b"clean\n"); stdout.clear(); assert_eq!( execute_local( &config, &CommandRequest::Git(GitRequest::Log { maximum: None }), &mut stdout, &mut stderr, ) .expect("memory output cannot fail"), EXIT_SUCCESS ); let log = String::from_utf8(stdout.clone())?; for message in [ "Set GPG id", "Add given password", "Edit password", "Copy nested/new", "Rename nested/copied", "Remove nested/moved", ] { assert!(log.contains(message), "missing automatic commit {message}"); } let entry = EntryPath::parse("nested/new")?; let recipients = ironstorage::recipient::RecipientPolicyManager::new(&repository, &keys) .resolve_for_entry(&entry, None)?; let replacement = keys.encrypt( SecretBytes::new(b"changed secret\nmetadata".to_vec()), recipients.recipients(), )?; repository.write_entry(&entry, &replacement)?; stdout.clear(); assert_eq!( execute_secure_with_services( &config, &CommandRequest::Git(GitRequest::Diff { paths: vec!["nested/new.gpg".to_owned()], }), &mut secrets, &mut presentation, &mut interaction, || Ok(0), &mut stdout, &mut stderr, ) .expect("memory output cannot fail"), EXIT_SUCCESS ); assert!( stdout .windows(b"-edited secret\n".len()) .any(|part| part == b"-edited secret\n") ); assert!( stdout .windows(b"+changed secret\n".len()) .any(|part| part == b"+changed secret\n") ); assert!(!stdout.windows(5).any(|part| part == b"-----")); assert!(stderr.is_empty()); Ok(()) } #[test] fn completion_output_and_source_process_boundary_are_audited() -> TestResult { let mut stdout = Vec::new(); let mut stderr = Vec::new(); assert_eq!( run_with( ["ironstorage", "completion", "bash"], &mut stdout, &mut stderr, ) .expect("memory output cannot fail"), EXIT_SUCCESS ); assert!(stdout.windows(11).any(|part| part == b"ironstorage")); assert!(stderr.is_empty()); let workspace = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let mut rust_sources = Vec::new(); collect_rust_sources(&workspace.join("apps"), &mut rust_sources)?; collect_rust_sources(&workspace.join("crates"), &mut rust_sources)?; let unsafe_block = ["unsafe", " {"].concat(); let unsafe_function = ["unsafe", " fn"].concat(); let unsafe_implementation = ["unsafe", " impl"].concat(); let command_constructor = ["Command", "::new("].concat(); let process_command = ["process", "::Command"].concat(); for source in rust_sources { let contents = fs::read_to_string(&source)?; assert!( !contents.contains(&unsafe_block), "forbidden block in {}", source.display() ); assert!( !contents.contains(&unsafe_function), "forbidden function in {}", source.display() ); assert!( !contents.contains(&unsafe_implementation), "forbidden implementation in {}", source.display() ); if contents.contains(&command_constructor) || contents.contains(&process_command) { assert!( source.ends_with("apps/cli/src/editor.rs"), "process launch outside editor adapter: {}", source.display() ); } } Ok(()) } fn collect_rust_sources( directory: &std::path::Path, output: &mut Vec, ) -> std::io::Result<()> { for entry in fs::read_dir(directory)? { let path = entry?.path(); if path.is_dir() { collect_rust_sources(&path, output)?; } else if path.extension().and_then(std::ffi::OsStr::to_str) == Some("rs") { output.push(path); } } Ok(()) } fn fixture_secrets(fingerprint: &str) -> Result, SecretStoreError> { let secrets = SecretStore::new( MemoryBackend::default(), SecretCachePolicy::Disabled, SecretProtectionPolicy::device_unlocked(), ); secrets.unlock()?; secrets.create( &SecretReference::openpgp_passphrase(fingerprint)?, SecretBytes::new(b"fixture-alice-passphrase".to_vec()), )?; Ok(secrets) } }