Implement clipboard and QR presentation
This commit is contained in:
@@ -11,6 +11,7 @@ name = "ironstorage"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
ctrlc.workspace = true
|
||||
ironstorage.workspace = true
|
||||
tempfile = "3"
|
||||
|
||||
|
||||
@@ -1,23 +1,42 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::disallowed_types)]
|
||||
|
||||
use std::{ffi::OsString, io::Write, process::ExitCode};
|
||||
use std::{
|
||||
error::Error,
|
||||
ffi::OsString,
|
||||
fmt,
|
||||
io::Write,
|
||||
path::Path,
|
||||
process::ExitCode,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ironstorage::{
|
||||
command::{
|
||||
CliAction, CommandRequest, EXIT_CONFIG, EXIT_FAILURE, EXIT_SUCCESS, EXIT_UNAVAILABLE,
|
||||
GitRequest, HelpTopic, OtpRequest, Presentation, help_text, otp_version_text, parse_from,
|
||||
version_text,
|
||||
GeneratedPresentation, GitRequest, HelpTopic, OtpRequest, help_text, otp_version_text,
|
||||
parse_from, version_text,
|
||||
},
|
||||
config::Config,
|
||||
crypto::KeyStore,
|
||||
generate::{GeneratorConfig, PasswordGenerator},
|
||||
git::{GitIdentity, GitRepository},
|
||||
read::{ShowOutput, ShowResult, VaultReader},
|
||||
repository::Repository,
|
||||
presentation::{
|
||||
ClipboardError, ClipboardTimeout, ClipboardWait, NativeClipboardManager, QrError, QrMatrix,
|
||||
},
|
||||
read::{PresentationChannel, ShowOutput, ShowResult, VaultReader},
|
||||
repository::{Repository, SecretBytes},
|
||||
secret_store::{
|
||||
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStore,
|
||||
SecretStoreBackend,
|
||||
},
|
||||
write::{
|
||||
EntryCommit, EntryCommitError, EntryCommitter, NoGitEntryCommitter, OverwriteDecision,
|
||||
},
|
||||
};
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -120,7 +139,9 @@ where
|
||||
fn needs_secret_store(request: &CommandRequest) -> bool {
|
||||
matches!(
|
||||
request,
|
||||
CommandRequest::Show(_) | CommandRequest::Git(GitRequest::Fetch { .. })
|
||||
CommandRequest::Show(_)
|
||||
| CommandRequest::Generate(_)
|
||||
| CommandRequest::Git(GitRequest::Fetch { .. })
|
||||
)
|
||||
}
|
||||
|
||||
@@ -130,9 +151,27 @@ fn execute_secure<B: SecretStoreBackend, O: Write, E: Write>(
|
||||
secrets: &mut SecretStore<B>,
|
||||
stdout: &mut O,
|
||||
stderr: &mut E,
|
||||
) -> Result<u8, ()> {
|
||||
execute_secure_with(
|
||||
config,
|
||||
request,
|
||||
secrets,
|
||||
&mut NativePresentation,
|
||||
stdout,
|
||||
stderr,
|
||||
)
|
||||
}
|
||||
|
||||
fn execute_secure_with<B: SecretStoreBackend, P: CliPresentation, O: Write, E: Write>(
|
||||
config: &Config,
|
||||
request: &CommandRequest,
|
||||
secrets: &mut SecretStore<B>,
|
||||
presentation: &mut P,
|
||||
stdout: &mut O,
|
||||
stderr: &mut E,
|
||||
) -> Result<u8, ()> {
|
||||
match request {
|
||||
CommandRequest::Show(request) if request.presentation == Presentation::Terminal => {
|
||||
CommandRequest::Show(request) => {
|
||||
let repository = match Repository::open(config.vault()) {
|
||||
Ok(repository) => repository,
|
||||
Err(error) => return operation_error(stderr, error),
|
||||
@@ -152,7 +191,71 @@ fn execute_secure<B: SecretStoreBackend, O: Write, E: Write>(
|
||||
.map_err(|_| ())?;
|
||||
Ok(EXIT_SUCCESS)
|
||||
}
|
||||
Ok(ShowOutput::Present(_)) => Ok(EXIT_UNAVAILABLE),
|
||||
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::Generate(request) => {
|
||||
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 mut committer = match generation_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,
|
||||
if request.force || request.in_place {
|
||||
OverwriteDecision::Allow
|
||||
} else {
|
||||
OverwriteDecision::Decline
|
||||
},
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -185,6 +288,149 @@ fn execute_secure<B: SecretStoreBackend, O: Write, E: Write>(
|
||||
}
|
||||
}
|
||||
|
||||
enum GenerationCommitter {
|
||||
Git(Box<GitRepository>),
|
||||
None(NoGitEntryCommitter),
|
||||
}
|
||||
|
||||
impl EntryCommitter for GenerationCommitter {
|
||||
fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> {
|
||||
match self {
|
||||
Self::Git(git) => EntryCommitter::commit(git.as_mut(), change),
|
||||
Self::None(committer) => committer.commit(change),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generation_committer(
|
||||
repository: &Repository,
|
||||
entry: &str,
|
||||
) -> Result<GenerationCommitter, ironstorage::git::GitError> {
|
||||
let identity = GitIdentity::new("IronStorage", "ironstorage@localhost")
|
||||
.expect("the built-in Git identity is valid");
|
||||
match GitRepository::open_innermost(repository, Path::new(entry), identity) {
|
||||
Ok(git) => Ok(GenerationCommitter::Git(Box::new(git))),
|
||||
Err(ironstorage::git::GitError::NotRepository) => {
|
||||
Ok(GenerationCommitter::None(NoGitEntryCommitter))
|
||||
}
|
||||
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<ClipboardError> for PresentationFailure {
|
||||
fn from(error: ClipboardError) -> Self {
|
||||
Self::Clipboard(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<QrError> for PresentationFailure {
|
||||
fn from(error: QrError) -> Self {
|
||||
Self::Qr(error)
|
||||
}
|
||||
}
|
||||
|
||||
fn select_remote<'a>(
|
||||
config: &'a Config,
|
||||
requested: Option<&str>,
|
||||
@@ -215,11 +461,12 @@ mod tests {
|
||||
|
||||
use ironstorage::{
|
||||
command::{
|
||||
CommandRequest, EXIT_CONFIG, EXIT_SUCCESS, EXIT_UNAVAILABLE, EXIT_USAGE, Presentation,
|
||||
ShowRequest,
|
||||
CommandRequest, EXIT_CONFIG, EXIT_SUCCESS, EXIT_UNAVAILABLE, EXIT_USAGE,
|
||||
GenerateRequest, GeneratedPresentation, Presentation, ShowRequest,
|
||||
},
|
||||
config::Config,
|
||||
git::GitCredentialProvider as _,
|
||||
presentation::{ClipboardTimeout, QrMatrix},
|
||||
repository::SecretBytes,
|
||||
secret_store::{
|
||||
SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy,
|
||||
@@ -227,13 +474,52 @@ mod tests {
|
||||
},
|
||||
};
|
||||
|
||||
use super::{execute_secure, run_with};
|
||||
use super::{
|
||||
CliPresentation, PresentationFailure, execute_secure, execute_secure_with, run_with,
|
||||
wait_for_clipboard,
|
||||
};
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error>>;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct MemoryBackend(Arc<Mutex<BTreeMap<SecretLocator, SecretBytes>>>);
|
||||
|
||||
#[derive(Default)]
|
||||
struct MemoryPresentation {
|
||||
clipboard: Vec<Vec<u8>>,
|
||||
qr: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -288,6 +574,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[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();
|
||||
@@ -424,4 +724,149 @@ mod tests {
|
||||
assert_eq!(credential.password(), b"fixture-token");
|
||||
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(())
|
||||
}
|
||||
|
||||
fn fixture_secrets(fingerprint: &str) -> Result<SecretStore<MemoryBackend>, 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user