Implement clipboard and QR presentation

This commit is contained in:
Hermes Agent
2026-08-10 00:49:42 +00:00
parent b685a4864c
commit b8c614e141
17 changed files with 1368 additions and 18 deletions

View File

@@ -11,6 +11,7 @@ name = "ironstorage"
path = "src/main.rs"
[dependencies]
ctrlc.workspace = true
ironstorage.workspace = true
tempfile = "3"

View File

@@ -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)
}
}