From b8c614e1413f1ef83fcf422361ad815e3a497aca Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 10 Aug 2026 00:49:42 +0000 Subject: [PATCH] Implement clipboard and QR presentation --- Cargo.lock | 156 ++++++- Cargo.toml | 4 + DEPENDENCIES.md | 4 +- README.md | 2 + apps/cli/Cargo.toml | 1 + apps/cli/src/main.rs | 467 +++++++++++++++++++- crates/storage/Cargo.toml | 6 + crates/storage/src/config.rs | 26 +- crates/storage/src/lib.rs | 1 + crates/storage/src/presentation.rs | 301 +++++++++++++ crates/storage/src/presentation/platform.rs | 85 ++++ crates/storage/tests/config_contract.rs | 40 +- crates/storage/tests/presentation.rs | 231 ++++++++++ docs/configuration.md | 9 + docs/password-generation.md | 4 +- docs/presentation.md | 46 ++ docs/read-domains.md | 3 + 17 files changed, 1368 insertions(+), 18 deletions(-) create mode 100644 crates/storage/src/presentation.rs create mode 100644 crates/storage/src/presentation/platform.rs create mode 100644 crates/storage/tests/presentation.rs create mode 100644 docs/presentation.md diff --git a/Cargo.lock b/Cargo.lock index 91fead8..46f02c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -222,6 +222,24 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.59.0", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "arc-swap" version = "1.9.2" @@ -1391,6 +1409,17 @@ dependencies = [ "cipher", ] +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix 0.31.3", + "windows-sys 0.61.2", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -1675,6 +1704,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.13.1", + "block2 0.6.2", + "libc", "objc2 0.6.4", ] @@ -2015,6 +2046,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -2254,6 +2291,34 @@ dependencies = [ "slab", ] +[[package]] +name = "g2gen" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a7e0eb46f83a20260b850117d204366674e85d3a908d90865c78df9a6b1dfc" +dependencies = [ + "g2poly", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "g2p" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "539e2644c030d3bf4cd208cb842d2ce2f80e82e6e8472390bcef83ceba0d80ad" +dependencies = [ + "g2gen", + "g2poly", +] + +[[package]] +name = "g2poly" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312d2295c7302019c395cfb90dacd00a82a2eabd700429bba9c7a3f38dbbe11b" + [[package]] name = "generic-array" version = "0.14.7" @@ -3897,6 +3962,7 @@ name = "ironstorage" version = "0.1.0" dependencies = [ "apple-native-keyring-store", + "arboard", "cap-std", "cap-tempfile", "clap", @@ -3906,10 +3972,12 @@ dependencies = [ "hex", "keyring-core", "pgp", + "qrcode", "rand 0.8.7", "rand_chacha 0.3.1", "regex", "reqwest", + "rqrr", "rustix 1.1.4", "secret-service", "security-framework", @@ -3938,6 +4006,7 @@ dependencies = [ name = "ironstorage-cli" version = "0.1.0" dependencies = [ + "ctrlc", "ironstorage", "tempfile", ] @@ -4299,6 +4368,9 @@ name = "lru" version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] [[package]] name = "lru" @@ -4321,7 +4393,7 @@ version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" dependencies = [ - "nix", + "nix 0.29.0", "winapi", ] @@ -4523,6 +4595,18 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -5119,6 +5203,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -5293,6 +5387,17 @@ dependencies = [ "pest", ] +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset 0.5.7", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "pgp" version = "0.20.0" @@ -5603,6 +5708,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quick-xml" version = "0.41.0" @@ -6069,6 +6180,16 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "rqrr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbe87d9e8db95652c25ded2418150e00b08c2fde09e23ec15896d2c470c6631" +dependencies = [ + "g2p", + "lru 0.16.4", +] + [[package]] name = "rsa" version = "0.9.10" @@ -6992,13 +7113,13 @@ dependencies = [ "fancy-regex", "filedescriptor", "finl_unicode", - "fixedbitset", + "fixedbitset 0.4.2", "hex", "lazy_static", "libc", "log", "memmem", - "nix", + "nix 0.29.0", "num-derive", "num-traits", "ordered-float 4.6.0", @@ -7347,6 +7468,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -8553,6 +8685,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.4", + "thiserror 2.0.19", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index 3034310..bd709ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,10 +15,12 @@ rust-version = "1.92" [workspace.dependencies] apple-native-keyring-store = { version = "1.0", default-features = false, features = ["keychain", "protected"] } +arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] } cap-std = "4.0" cap-tempfile = "4.0" clap = { version = "4.6", features = ["derive"] } crossterm = "0.29" +ctrlc = "3.5" flate2 = "1.1" gix = { version = "0.86", default-features = false, features = ["blocking-http-transport-reqwest-rust-tls", "index", "merge", "revision", "sha1", "tree-editor"] } gix-config = "0.59" @@ -26,9 +28,11 @@ iced = "0.14" ironstorage = { path = "crates/storage" } keyring-core = "1.0" pgp = { version = "0.20", default-features = false } +qrcode = { version = "0.14", default-features = false } rand = "0.8" regex = "1.13" reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] } +rqrr = { version = "0.10", default-features = false } ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] } security-framework = "3.7" secret-service = { version = "5.1", default-features = false, features = ["rt-tokio-crypto-rust"] } diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index e7ea3a7..472c9ab 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -52,7 +52,9 @@ decision. | Secret values in memory | [`secrecy` 0.10](https://crates.io/crates/secrecy/0.10.3), [`zeroize` 1.9](https://crates.io/crates/zeroize/1.9.0) | MIT OR Apache-2.0 | `zeroize` selected for the storage-owned redacted byte type; consider `secrecy` only when typed exposure controls add value. | | Password generation | [`rand`](https://crates.io/crates/rand) | MIT OR Apache-2.0 | Preferred using the operating-system CSPRNG. | | TOTP and HOTP | [`hmac`](https://crates.io/crates/hmac), [`sha1`](https://crates.io/crates/sha1), [`sha2`](https://crates.io/crates/sha2), [`data-encoding`](https://crates.io/crates/data-encoding), [`url`](https://crates.io/crates/url) | MIT or MIT OR Apache-2.0 | Preferred small implementation with RFC test vectors. `totp-rs` is MIT but rejects HOTP URIs, so it cannot cover all of `pass-otp`. | -| QR output and desktop image input | [`qrcode` 0.14](https://crates.io/crates/qrcode/0.14.1), [`rqrr` 0.10](https://crates.io/crates/rqrr/0.10.1) | MIT OR Apache-2.0; second crate also includes ISC | Suitable. Apple camera scanning should use AVFoundation and pass only the decoded URI to Rust. | +| Native desktop clipboard | [`arboard` 3.6](https://crates.io/crates/arboard/3.6.1) | MIT OR Apache-2.0 | Selected with image support disabled and Wayland data-control enabled. Storage owns timeout, cleanup, and newer-content race policy; the safe adapter provides macOS, Windows, X11, and Wayland text access without helper processes. | +| CLI cancellation | [`ctrlc` 3.5](https://crates.io/crates/ctrlc/3.5.2) | MIT OR Apache-2.0 | Selected for cross-platform interruption of the blocking clipboard lease. Ctrl-C requests storage cleanup before the CLI returns cancellation. | +| QR output and desktop image input | [`qrcode` 0.14](https://crates.io/crates/qrcode/0.14.1), [`rqrr` 0.10](https://crates.io/crates/rqrr/0.10.1) | MIT OR Apache-2.0; second crate also includes ISC | `qrcode` selected without image features for storage-owned matrices and terminal rendering. `rqrr` is test-only round-trip verification. Apple camera scanning should use AVFoundation and pass only decoded bytes to Rust. | | Atomic file replacement and scoped filesystem access | [`cap-std` 4.0](https://crates.io/crates/cap-std/4.0.2), [`cap-tempfile` 4.0](https://crates.io/crates/cap-tempfile/4.0.2) | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | Selected. Directory capabilities prevent vault escape; temporary files are anonymous where supported and are synced before atomic replacement. | With this path, the central crate needs no third-party native GPG, Git, OTP, or diff --git a/README.md b/README.md index fad50d0..cf24d6d 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ documented in [`docs/git-synchronization.md`](docs/git-synchronization.md). Native credential storage, opaque secret references, user-presence policy, and bounded caching are documented in [`docs/secure-secret-storage.md`](docs/secure-secret-storage.md). +Clipboard cleanup/race behavior and platform-neutral QR rendering are +documented in [`docs/presentation.md`](docs/presentation.md). The capability-scoped password-store layout and atomic mutation guarantees are documented in [`docs/repository-core.md`](docs/repository-core.md). The embedded OpenPGP backend, exported-key model, secret-provider boundary, and diff --git a/apps/cli/Cargo.toml b/apps/cli/Cargo.toml index 365ef4f..79e6747 100644 --- a/apps/cli/Cargo.toml +++ b/apps/cli/Cargo.toml @@ -11,6 +11,7 @@ name = "ironstorage" path = "src/main.rs" [dependencies] +ctrlc.workspace = true ironstorage.workspace = true tempfile = "3" diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index 729424b..e9742ba 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -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( secrets: &mut SecretStore, stdout: &mut O, stderr: &mut E, +) -> Result { + execute_secure_with( + config, + request, + secrets, + &mut NativePresentation, + stdout, + stderr, + ) +} + +fn execute_secure_with( + config: &Config, + request: &CommandRequest, + secrets: &mut SecretStore, + presentation: &mut P, + stdout: &mut O, + stderr: &mut E, ) -> Result { 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( .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( } } +enum GenerationCommitter { + Git(Box), + 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 { + 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 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>, @@ -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>; #[derive(Clone, Default)] struct MemoryBackend(Arc>>); + #[derive(Default)] + struct MemoryPresentation { + clipboard: Vec>, + qr: Vec>, + } + + 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, 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) + } } diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index e3f3758..a21fbdb 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -15,6 +15,7 @@ gix.workspace = true gix-config.workspace = true keyring-core.workspace = true pgp.workspace = true +qrcode.workspace = true rand.workspace = true regex.workspace = true reqwest.workspace = true @@ -33,12 +34,17 @@ security-framework.workspace = true windows-native-keyring-store.workspace = true [target.'cfg(target_os = "linux")'.dependencies] +arboard.workspace = true secret-service.workspace = true zbus-secret-service-keyring-store.workspace = true +[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies] +arboard.workspace = true + [dev-dependencies] hex = "0.4" rand_chacha = "0.3" +rqrr.workspace = true rustix = { version = "1.1", features = ["fs"] } sha2 = "0.10" smallvec = "1.15" diff --git a/crates/storage/src/config.rs b/crates/storage/src/config.rs index 6ce5fd1..6744d67 100644 --- a/crates/storage/src/config.rs +++ b/crates/storage/src/config.rs @@ -6,11 +6,14 @@ use std::{ error::Error, fmt, fs, path::{Component, Path, PathBuf}, + time::Duration, }; use serde::Deserialize; use url::Url; +use crate::presentation::{ClipboardTimeout, DEFAULT_CLIPBOARD_TIMEOUT}; + const APPLICATION_DIRECTORY: &str = "ironstorage"; const CONFIG_FILE: &str = "config.toml"; const MAX_CONFIG_BYTES: u64 = 1024 * 1024; @@ -23,6 +26,7 @@ pub struct Config { default_key: KeyIdentity, key_material: PathBuf, editor: Option, + clipboard_timeout: ClipboardTimeout, git_remotes: Vec, } @@ -52,6 +56,10 @@ impl Config { self.editor.as_ref() } + pub fn clipboard_timeout(&self) -> ClipboardTimeout { + self.clipboard_timeout + } + pub fn git_remotes(&self) -> &[GitRemote] { &self.git_remotes } @@ -400,6 +408,7 @@ struct RawConfig { default_key: Option, key_material: Option, editor: Option, + clipboard_timeout_seconds: Option, #[serde(default)] git: RawGit, } @@ -457,6 +466,13 @@ fn validate_config(source: PathBuf, raw: RawConfig) -> Result Result Result<(), Confi validate_table( root, "", - &["vault", "default_key", "key_material", "editor", "git"], + &[ + "vault", + "default_key", + "key_material", + "editor", + "clipboard_timeout_seconds", + "git", + ], )?; let Some(git) = root.get("git") else { return Ok(()); diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index a715bd5..76388c4 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -11,6 +11,7 @@ pub mod crypto; pub mod generate; pub mod git; pub mod mutation; +pub mod presentation; pub mod read; pub mod recipient; pub mod repository; diff --git a/crates/storage/src/presentation.rs b/crates/storage/src/presentation.rs new file mode 100644 index 0000000..a3a72b8 --- /dev/null +++ b/crates/storage/src/presentation.rs @@ -0,0 +1,301 @@ +//! Secret-safe clipboard lifecycle and platform-neutral QR presentation. + +use std::{error::Error, fmt, time::Duration}; + +use qrcode::{EcLevel, QrCode, types::Color}; +use zeroize::Zeroize as _; + +use crate::repository::SecretBytes; + +mod platform; + +pub const DEFAULT_CLIPBOARD_TIMEOUT: Duration = Duration::from_secs(45); +pub const MAX_CLIPBOARD_TIMEOUT: Duration = Duration::from_secs(5 * 60); +const QR_QUIET_ZONE: usize = 4; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ClipboardTimeout(Duration); + +impl ClipboardTimeout { + pub fn new(duration: Duration) -> Result { + if duration.is_zero() || duration > MAX_CLIPBOARD_TIMEOUT { + return Err(ClipboardError::InvalidTimeout); + } + Ok(Self(duration)) + } + + pub const fn pass_default() -> Self { + Self(DEFAULT_CLIPBOARD_TIMEOUT) + } + + pub const fn duration(self) -> Duration { + self.0 + } +} + +impl Default for ClipboardTimeout { + fn default() -> Self { + Self::pass_default() + } +} + +pub enum ClipboardContent { + Text(SecretBytes), + EmptyOrNonText, +} + +impl ClipboardContent { + pub fn text(value: Vec) -> Self { + Self::Text(SecretBytes::new(value)) + } +} + +impl fmt::Debug for ClipboardContent { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Text(_) => formatter.write_str("ClipboardContent::Text([REDACTED])"), + Self::EmptyOrNonText => formatter.write_str("ClipboardContent::EmptyOrNonText"), + } + } +} + +/// Byte-oriented adapter contract for native clipboards and deterministic tests. +pub trait ClipboardBackend { + fn read(&mut self) -> Result; + fn write(&mut self, value: &SecretBytes) -> Result<(), ClipboardError>; + fn clear(&mut self) -> Result<(), ClipboardError>; +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ClipboardWait { + Elapsed, + Cancelled, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ClipboardDisposition { + RestoredPrevious, + Cleared, + PreservedNewer, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ClipboardError { + InvalidTimeout, + EmptySecret, + NonUtf8, + Unavailable, + ReadFailed, + WriteFailed, + CleanupFailed, + Cancelled, +} + +impl fmt::Display for ClipboardError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::InvalidTimeout => "the clipboard timeout is invalid", + Self::EmptySecret => "empty data cannot be presented on the clipboard", + Self::NonUtf8 => "the native clipboard accepts only UTF-8 text", + Self::Unavailable => "the native clipboard is unavailable", + Self::ReadFailed => "the native clipboard could not be read", + Self::WriteFailed => "the native clipboard could not be written", + Self::CleanupFailed => "the clipboard secret could not be cleaned up safely", + Self::Cancelled => "clipboard presentation was cancelled", + }; + formatter.write_str(message) + } +} + +impl Error for ClipboardError {} + +pub struct ClipboardManager { + backend: B, + timeout: ClipboardTimeout, +} + +impl fmt::Debug for ClipboardManager { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClipboardManager") + .field("timeout", &self.timeout) + .field("contents", &"[REDACTED]") + .finish() + } +} + +impl ClipboardManager { + pub fn new(backend: B, timeout: ClipboardTimeout) -> Self { + Self { backend, timeout } + } + + pub fn timeout(&self) -> ClipboardTimeout { + self.timeout + } + + /// Copy a secret, wait under caller-controlled scheduling, and then clean up. + /// + /// Cleanup restores the previous UTF-8 value (or clears a previous non-text + /// value) only while the copied secret is still current. A newer clipboard + /// value is never overwritten. + pub fn copy_with( + &mut self, + value: &SecretBytes, + wait: impl FnOnce(Duration) -> ClipboardWait, + ) -> Result { + if value.expose().is_empty() { + return Err(ClipboardError::EmptySecret); + } + let previous = self.backend.read()?; + self.backend.write(value)?; + let wait_result = wait(self.timeout.duration()); + let disposition = self.cleanup(value, &previous)?; + if wait_result == ClipboardWait::Cancelled { + return Err(ClipboardError::Cancelled); + } + Ok(disposition) + } + + fn cleanup( + &mut self, + copied: &SecretBytes, + previous: &ClipboardContent, + ) -> Result { + let current = self + .backend + .read() + .map_err(|_| ClipboardError::CleanupFailed)?; + let ClipboardContent::Text(current) = current else { + return Ok(ClipboardDisposition::PreservedNewer); + }; + if current.expose() != copied.expose() { + return Ok(ClipboardDisposition::PreservedNewer); + } + match previous { + ClipboardContent::Text(previous) => self + .backend + .write(previous) + .map(|()| ClipboardDisposition::RestoredPrevious) + .map_err(|_| ClipboardError::CleanupFailed), + ClipboardContent::EmptyOrNonText => self + .backend + .clear() + .map(|()| ClipboardDisposition::Cleared) + .map_err(|_| ClipboardError::CleanupFailed), + } + } +} + +pub type NativeClipboardManager = ClipboardManager; + +impl NativeClipboardManager { + pub fn system(timeout: ClipboardTimeout) -> Result { + Ok(Self::new(platform::NativeClipboardBackend::new()?, timeout)) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum QrError { + EmptyPayload, + PayloadTooLarge, +} + +impl fmt::Display for QrError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyPayload => formatter.write_str("empty data cannot be encoded as a QR code"), + Self::PayloadTooLarge => formatter.write_str("the QR payload is too large"), + } + } +} + +impl Error for QrError {} + +/// A secret-derived, zeroizing QR symbol without presentation-platform choices. +pub struct QrMatrix { + width: usize, + modules: Vec, +} + +impl QrMatrix { + pub fn encode(payload: &SecretBytes) -> Result { + if payload.expose().is_empty() { + return Err(QrError::EmptyPayload); + } + let code = QrCode::with_error_correction_level(payload.expose(), EcLevel::L) + .map_err(|_| QrError::PayloadTooLarge)?; + let width = code.width(); + let modules = code + .into_colors() + .into_iter() + .map(|color| u8::from(color == Color::Dark)) + .collect(); + Ok(Self { width, modules }) + } + + pub fn width(&self) -> usize { + self.width + } + + pub fn is_dark(&self, x: usize, y: usize) -> Option { + if x >= self.width || y >= self.width { + return None; + } + Some(self.modules[y * self.width + x] != 0) + } + + /// Render a QR symbol with the standard four-module quiet zone and square + /// terminal cells. The returned bytes are secret-derived and zeroize on drop. + pub fn render_terminal(&self) -> SecretBytes { + let padded_width = self.width + 2 * QR_QUIET_ZONE; + let padded_height = padded_width.next_multiple_of(2); + let mut rendered = String::with_capacity(padded_width * padded_height * 2); + for y in (0..padded_height).step_by(2) { + for x in 0..padded_width { + let top = self.padded_module(x, y); + let bottom = self.padded_module(x, y + 1); + let cell = match (top, bottom) { + (true, true) => '█', + (true, false) => '▀', + (false, true) => '▄', + (false, false) => ' ', + }; + rendered.push(cell); + rendered.push(cell); + } + rendered.push('\n'); + } + SecretBytes::new(rendered.into_bytes()) + } + + fn padded_module(&self, x: usize, y: usize) -> bool { + let Some(x) = x.checked_sub(QR_QUIET_ZONE) else { + return false; + }; + let Some(y) = y.checked_sub(QR_QUIET_ZONE) else { + return false; + }; + self.is_dark(x, y).unwrap_or(false) + } +} + +impl Clone for QrMatrix { + fn clone(&self) -> Self { + Self { + width: self.width, + modules: self.modules.clone(), + } + } +} + +impl fmt::Debug for QrMatrix { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("QrMatrix([REDACTED])") + } +} + +impl Drop for QrMatrix { + fn drop(&mut self) { + self.modules.zeroize(); + } +} diff --git a/crates/storage/src/presentation/platform.rs b/crates/storage/src/presentation/platform.rs new file mode 100644 index 0000000..d8dc9b4 --- /dev/null +++ b/crates/storage/src/presentation/platform.rs @@ -0,0 +1,85 @@ +//! Safe native clipboard adapter selection. +//! +//! Desktop adapters are supplied by `arboard`, which owns the operating-system +//! integration. IronStorage performs no direct FFI and contains no unsafe code. + +use super::{ClipboardBackend, ClipboardContent, ClipboardError}; +use crate::repository::SecretBytes; + +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +pub struct NativeClipboardBackend { + clipboard: arboard::Clipboard, +} + +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +impl NativeClipboardBackend { + pub fn new() -> Result { + arboard::Clipboard::new() + .map(|clipboard| Self { clipboard }) + .map_err(|_| ClipboardError::Unavailable) + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +impl ClipboardBackend for NativeClipboardBackend { + fn read(&mut self) -> Result { + match self.clipboard.get_text() { + Ok(value) => Ok(ClipboardContent::text(value.into_bytes())), + Err(arboard::Error::ContentNotAvailable) => Ok(ClipboardContent::EmptyOrNonText), + Err(arboard::Error::ClipboardNotSupported) => Err(ClipboardError::Unavailable), + Err(_) => Err(ClipboardError::ReadFailed), + } + } + + fn write(&mut self, value: &SecretBytes) -> Result<(), ClipboardError> { + let text = std::str::from_utf8(value.expose()).map_err(|_| ClipboardError::NonUtf8)?; + set_text(&mut self.clipboard, text).map_err(|error| match error { + arboard::Error::ClipboardNotSupported => ClipboardError::Unavailable, + _ => ClipboardError::WriteFailed, + }) + } + + fn clear(&mut self) -> Result<(), ClipboardError> { + self.clipboard.clear().map_err(|error| match error { + arboard::Error::ClipboardNotSupported => ClipboardError::Unavailable, + _ => ClipboardError::WriteFailed, + }) + } +} + +#[cfg(target_os = "linux")] +fn set_text(clipboard: &mut arboard::Clipboard, text: &str) -> Result<(), arboard::Error> { + use arboard::SetExtLinux as _; + + clipboard.set().exclude_from_history().text(text) +} + +#[cfg(any(target_os = "macos", target_os = "windows"))] +fn set_text(clipboard: &mut arboard::Clipboard, text: &str) -> Result<(), arboard::Error> { + clipboard.set_text(text) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +pub struct NativeClipboardBackend; + +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +impl NativeClipboardBackend { + pub fn new() -> Result { + Err(ClipboardError::Unavailable) + } +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +impl ClipboardBackend for NativeClipboardBackend { + fn read(&mut self) -> Result { + Err(ClipboardError::Unavailable) + } + + fn write(&mut self, _value: &SecretBytes) -> Result<(), ClipboardError> { + Err(ClipboardError::Unavailable) + } + + fn clear(&mut self) -> Result<(), ClipboardError> { + Err(ClipboardError::Unavailable) + } +} diff --git a/crates/storage/tests/config_contract.rs b/crates/storage/tests/config_contract.rs index 101bb3f..00256e0 100644 --- a/crates/storage/tests/config_contract.rs +++ b/crates/storage/tests/config_contract.rs @@ -1,8 +1,9 @@ #![forbid(unsafe_code)] -use std::{error::Error, ffi::OsStr, fs, path::Path}; +use std::{error::Error, ffi::OsStr, fs, path::Path, time::Duration}; use ironstorage::config::{ConfigError, ConfigLoader, EditorSource}; +use ironstorage::presentation::DEFAULT_CLIPBOARD_TIMEOUT; use tempfile::TempDir; type TestResult = Result<(), Box>; @@ -78,6 +79,43 @@ fn explicit_relative_configuration_resolves_deterministically() -> TestResult { ); assert_eq!(remote.server_id().as_str(), "personal-git"); assert_eq!(remote.application_id().as_str(), "ironstorage-cli"); + assert_eq!( + config.clipboard_timeout().duration(), + DEFAULT_CLIPBOARD_TIMEOUT + ); + Ok(()) +} + +#[test] +fn clipboard_timeout_defaults_overrides_and_rejects_unsafe_values() -> TestResult { + let fixture = ConfigurationFixture::new()?; + fixture.write_explicit(&fixture.valid_contents().replace( + "editor = [\"code\", \"--wait\"]", + "editor = [\"code\", \"--wait\"]\nclipboard_timeout_seconds = 30", + ))?; + let config = fixture + .loader() + .load(Some(Path::new("config/config.toml")))?; + assert_eq!( + config.clipboard_timeout().duration(), + Duration::from_secs(30) + ); + + for timeout in [0, 301] { + fixture.write_explicit(&fixture.valid_contents().replace( + "editor = [\"code\", \"--wait\"]", + &format!("editor = [\"code\", \"--wait\"]\nclipboard_timeout_seconds = {timeout}"), + ))?; + assert_eq!( + fixture + .loader() + .load(Some(Path::new("config/config.toml"))) + .expect_err("unsafe clipboard timeout"), + ConfigError::InvalidField { + field: "clipboard_timeout_seconds" + } + ); + } Ok(()) } diff --git a/crates/storage/tests/presentation.rs b/crates/storage/tests/presentation.rs new file mode 100644 index 0000000..f6cd792 --- /dev/null +++ b/crates/storage/tests/presentation.rs @@ -0,0 +1,231 @@ +#![forbid(unsafe_code)] + +use std::{ + error::Error, + sync::{Arc, Mutex}, + time::Duration, +}; + +use ironstorage::{ + presentation::{ + ClipboardBackend, ClipboardContent, ClipboardDisposition, ClipboardError, ClipboardManager, + ClipboardTimeout, ClipboardWait, DEFAULT_CLIPBOARD_TIMEOUT, QrError, QrMatrix, + }, + repository::SecretBytes, +}; + +type TestResult = Result<(), Box>; + +#[derive(Default)] +struct MemoryState { + text: Option>, + fail_read: bool, + fail_write: bool, + fail_clear: bool, +} + +#[derive(Clone, Default)] +struct MemoryClipboard(Arc>); + +impl MemoryClipboard { + fn with_text(value: &[u8]) -> Self { + let backend = Self::default(); + backend.set_text(value); + backend + } + + fn set_text(&self, value: &[u8]) { + let mut state = self.0.lock().expect("test clipboard mutex"); + state.text = Some(value.to_vec()); + } + + fn text(&self) -> Option> { + self.0.lock().expect("test clipboard mutex").text.clone() + } + + fn fail_cleanup_read(&self) { + self.0.lock().expect("test clipboard mutex").fail_read = true; + } +} + +impl ClipboardBackend for MemoryClipboard { + fn read(&mut self) -> Result { + let mut state = self.0.lock().map_err(|_| ClipboardError::ReadFailed)?; + if std::mem::take(&mut state.fail_read) { + return Err(ClipboardError::ReadFailed); + } + match &state.text { + Some(value) => Ok(ClipboardContent::text(value.clone())), + None => Ok(ClipboardContent::EmptyOrNonText), + } + } + + fn write(&mut self, value: &SecretBytes) -> Result<(), ClipboardError> { + let mut state = self.0.lock().map_err(|_| ClipboardError::WriteFailed)?; + if std::mem::take(&mut state.fail_write) { + return Err(ClipboardError::WriteFailed); + } + state.text = Some(value.expose().to_vec()); + Ok(()) + } + + fn clear(&mut self) -> Result<(), ClipboardError> { + let mut state = self.0.lock().map_err(|_| ClipboardError::WriteFailed)?; + if std::mem::take(&mut state.fail_clear) { + return Err(ClipboardError::WriteFailed); + } + state.text = None; + Ok(()) + } +} + +#[test] +fn clipboard_timeout_restores_previous_text_and_clears_empty_content() -> TestResult { + let backend = MemoryClipboard::with_text(b"previous clipboard"); + let observer = backend.clone(); + let mut manager = ClipboardManager::new(backend, ClipboardTimeout::pass_default()); + let secret = SecretBytes::new(b"copied secret".to_vec()); + let disposition = manager.copy_with(&secret, |duration| { + assert_eq!(duration, DEFAULT_CLIPBOARD_TIMEOUT); + assert_eq!( + observer.text().as_deref(), + Some(b"copied secret".as_slice()) + ); + ClipboardWait::Elapsed + })?; + assert_eq!(disposition, ClipboardDisposition::RestoredPrevious); + assert_eq!( + observer.text().as_deref(), + Some(b"previous clipboard".as_slice()) + ); + + let backend = MemoryClipboard::default(); + let observer = backend.clone(); + let mut manager = ClipboardManager::new(backend, ClipboardTimeout::pass_default()); + assert_eq!( + manager.copy_with(&secret, |_| ClipboardWait::Elapsed)?, + ClipboardDisposition::Cleared + ); + assert!(observer.text().is_none()); + assert!(!format!("{manager:?}").contains("copied secret")); + Ok(()) +} + +#[test] +fn clipboard_races_and_cancellation_never_overwrite_newer_user_content() -> TestResult { + let backend = MemoryClipboard::with_text(b"previous"); + let observer = backend.clone(); + let mut manager = ClipboardManager::new(backend, ClipboardTimeout::pass_default()); + let secret = SecretBytes::new(b"secret".to_vec()); + assert_eq!( + manager.copy_with(&secret, |_| { + observer.set_text(b"new user value"); + ClipboardWait::Elapsed + })?, + ClipboardDisposition::PreservedNewer + ); + assert_eq!( + observer.text().as_deref(), + Some(b"new user value".as_slice()) + ); + + observer.set_text(b"previous"); + assert_eq!( + manager.copy_with(&secret, |_| ClipboardWait::Cancelled), + Err(ClipboardError::Cancelled) + ); + assert_eq!(observer.text().as_deref(), Some(b"previous".as_slice())); + Ok(()) +} + +#[test] +fn clipboard_validation_and_cleanup_failures_are_typed_and_redacted() -> TestResult { + assert_eq!( + ClipboardTimeout::new(Duration::ZERO), + Err(ClipboardError::InvalidTimeout) + ); + assert_eq!( + ClipboardTimeout::new(Duration::from_secs(301)), + Err(ClipboardError::InvalidTimeout) + ); + let backend = MemoryClipboard::default(); + let observer = backend.clone(); + let mut manager = ClipboardManager::new(backend, ClipboardTimeout::pass_default()); + assert_eq!( + manager.copy_with(&SecretBytes::new(Vec::new()), |_| ClipboardWait::Elapsed), + Err(ClipboardError::EmptySecret) + ); + let secret = SecretBytes::new(b"redacted secret".to_vec()); + let content = ClipboardContent::text(b"redacted previous clipboard".to_vec()); + assert!(!format!("{content:?}").contains("redacted previous clipboard")); + assert_eq!( + manager.copy_with(&secret, |_| { + observer.fail_cleanup_read(); + ClipboardWait::Elapsed + }), + Err(ClipboardError::CleanupFailed) + ); + assert!( + !ClipboardError::CleanupFailed + .to_string() + .contains("redacted secret") + ); + Ok(()) +} + +#[test] +fn otp_codes_and_uris_use_the_same_secret_safe_clipboard_lifecycle() -> TestResult { + for payload in [ + b"287082".as_slice(), + b"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example".as_slice(), + ] { + let backend = MemoryClipboard::default(); + let observer = backend.clone(); + let mut manager = ClipboardManager::new(backend, ClipboardTimeout::pass_default()); + let secret = SecretBytes::new(payload.to_vec()); + assert_eq!( + manager.copy_with(&secret, |_| { + assert_eq!(observer.text().as_deref(), Some(payload)); + ClipboardWait::Elapsed + })?, + ClipboardDisposition::Cleared + ); + assert!(observer.text().is_none()); + } + Ok(()) +} + +#[test] +fn qr_matrices_round_trip_and_render_without_plaintext() -> TestResult { + for payload in [ + "correct horse battery staple", + "otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example", + "Unicode password: 咖啡☕", + ] { + let secret = SecretBytes::new(payload.as_bytes().to_vec()); + let matrix = QrMatrix::encode(&secret)?; + let simple = rqrr::SimpleGrid::from_func(matrix.width(), |x, y| { + matrix.is_dark(x, y).expect("coordinates are in range") + }); + let (_, decoded) = rqrr::Grid::new(simple).decode()?; + assert_eq!(decoded, payload); + let terminal = matrix.render_terminal(); + assert!(terminal.expose().ends_with(b"\n")); + assert!( + !terminal + .expose() + .windows(payload.len()) + .any(|part| part == payload.as_bytes()) + ); + assert!(!format!("{matrix:?}").contains(payload)); + } + assert!(matches!( + QrMatrix::encode(&SecretBytes::new(Vec::new())), + Err(QrError::EmptyPayload) + )); + assert!(matches!( + QrMatrix::encode(&SecretBytes::new(vec![b'x'; 4096])), + Err(QrError::PayloadTooLarge) + )); + Ok(()) +} diff --git a/docs/configuration.md b/docs/configuration.md index a0e5c9a..246f816 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -26,6 +26,9 @@ key_material = "keys" # accepted and split without launching a shell. editor = ["code", "--wait"] +# Optional; upstream pass defaults to 45 seconds. Values are limited to 1..300. +clipboard_timeout_seconds = 45 + [[git.remotes]] name = "origin" url = "https://git.example.test/alice/password-store.git" @@ -49,6 +52,12 @@ store; duplicate names and duplicate reference pairs are errors. The HTTPS account name is stored inside the protected credential record, not in TOML. OpenPGP passphrases are addressed by the resolved primary fingerprint. +`clipboard_timeout_seconds` controls the native clipboard presentation lease. +It defaults to 45 seconds for upstream `pass` compatibility and must be between +1 and 300 seconds. The CLI remains alive for the lease so Linux can serve its +selection and every platform can restore or clear the value reliably without a +background helper process. + Passwords, passphrases, tokens, credentials, private keys, and other secret values are forbidden in TOML. Unknown fields are rejected. Parse errors never echo the source line or value, so an accidentally supplied secret is not diff --git a/docs/password-generation.md b/docs/password-generation.md index a63eec0..a67b802 100644 --- a/docs/password-generation.md +++ b/docs/password-generation.md @@ -16,7 +16,9 @@ Normal generation uses the same overwrite decision and `--force` rules as insert. `--in-place` requires an existing entry, decrypts it, replaces only the bytes before its first newline, and preserves that newline and every following byte exactly. The generated password is returned separately as redacted, -zeroizing data for typed terminal, clipboard, or QR presentation. +zeroizing data for typed terminal, clipboard, or QR presentation. Clipboard and +QR channels use the same storage-owned presentation service as `show`, so +generated plaintext is never printed for those modes. The completed entry is encrypted for the nearest recipient policy and written atomically. Embedded Git receives `Add generated password for ...` only after a diff --git a/docs/presentation.md b/docs/presentation.md new file mode 100644 index 0000000..b134fdd --- /dev/null +++ b/docs/presentation.md @@ -0,0 +1,46 @@ +# Clipboard and QR presentation + +`crates/storage` owns secret-presentation lifecycle and derived QR data. The +CLI receives an already selected `PresentationSecret` or generated password and +only writes storage-produced status or terminal-rendering bytes. It never +launches `xclip`, `wl-copy`, `pbcopy`, `qrencode`, an image viewer, or a shell. +The same byte-oriented APIs are ready for pass-otp code and URI results without +placing OTP parsing or calculation in a frontend. + +## Clipboard lifecycle + +The default clipboard lease is 45 seconds, matching upstream `pass`. +`clipboard_timeout_seconds` may configure 1 through 300 seconds. Before copying, +storage snapshots an existing UTF-8 clipboard value. At lease completion or +cancellation it reads the clipboard again: + +- if the copied secret remains current, storage restores the previous text or + clears a previous empty/non-text clipboard; +- if another application or the user supplied newer contents, storage leaves + those contents untouched; +- if cleanup cannot be verified or completed, the operation returns a typed + cleanup failure rather than claiming success. + +The CLI deliberately remains alive during the lease. This lets X11 and Wayland +serve the selection and guarantees cleanup without spawning a daemon or helper. +Linux also marks copied text with the commonly supported password-manager hint +to discourage clipboard-history retention. macOS uses NSPasteboard, Windows +uses the native clipboard, and Linux uses direct X11 or Wayland data-control +protocols through the safe `arboard` adapter. Unsupported targets return a +typed unavailable result. Tests inject a byte-oriented backend and cover +expiry, restore, clear, cancellation, failure, and newer-content races without +touching the developer clipboard. + +## QR data + +`QrMatrix` encodes secret bytes with the pure-Rust `qrcode` implementation and +returns only dimensions and dark/light module queries. Its module buffer, +terminal rendering, and source payload all use zeroizing/redacted storage +objects. The terminal renderer supplies the standard four-module quiet zone, +square half-block cells, and no plaintext label or payload. Frontends can draw +the same matrix natively without regenerating or interpreting it. + +Round-trip tests decode generated password, Unicode, and `otpauth://` matrices +with the test-only `rqrr` decoder. Empty and oversized payloads are typed errors, +and CLI tests prove `show` and `generate` clipboard/QR modes never emit their +plaintext values. diff --git a/docs/read-domains.md b/docs/read-domains.md index 14bb991..55d8bec 100644 --- a/docs/read-domains.md +++ b/docs/read-domains.md @@ -23,6 +23,9 @@ number. Storage selects that line directly from decrypted bytes and returns a `PresentationSecret` containing the logical entry, line, channel, and redacted zeroizing contents. The adapter never parses rendered terminal output, and a missing or empty requested line is an explicit failure. +The resulting secret is consumed by the shared presentation service documented +in [`presentation.md`](presentation.md); plaintext is not written to CLI output +for clipboard or QR requests. ## Name and plaintext search