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

156
Cargo.lock generated
View File

@@ -222,6 +222,24 @@ dependencies = [
"num-traits", "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]] [[package]]
name = "arc-swap" name = "arc-swap"
version = "1.9.2" version = "1.9.2"
@@ -1391,6 +1409,17 @@ dependencies = [
"cipher", "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]] [[package]]
name = "cursor-icon" name = "cursor-icon"
version = "1.2.0" version = "1.2.0"
@@ -1675,6 +1704,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"block2 0.6.2",
"libc",
"objc2 0.6.4", "objc2 0.6.4",
] ]
@@ -2015,6 +2046,12 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]] [[package]]
name = "flate2" name = "flate2"
version = "1.1.9" version = "1.1.9"
@@ -2254,6 +2291,34 @@ dependencies = [
"slab", "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]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.7" version = "0.14.7"
@@ -3897,6 +3962,7 @@ name = "ironstorage"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apple-native-keyring-store", "apple-native-keyring-store",
"arboard",
"cap-std", "cap-std",
"cap-tempfile", "cap-tempfile",
"clap", "clap",
@@ -3906,10 +3972,12 @@ dependencies = [
"hex", "hex",
"keyring-core", "keyring-core",
"pgp", "pgp",
"qrcode",
"rand 0.8.7", "rand 0.8.7",
"rand_chacha 0.3.1", "rand_chacha 0.3.1",
"regex", "regex",
"reqwest", "reqwest",
"rqrr",
"rustix 1.1.4", "rustix 1.1.4",
"secret-service", "secret-service",
"security-framework", "security-framework",
@@ -3938,6 +4006,7 @@ dependencies = [
name = "ironstorage-cli" name = "ironstorage-cli"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ctrlc",
"ironstorage", "ironstorage",
"tempfile", "tempfile",
] ]
@@ -4299,6 +4368,9 @@ name = "lru"
version = "0.16.4" version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
dependencies = [
"hashbrown 0.16.1",
]
[[package]] [[package]]
name = "lru" name = "lru"
@@ -4321,7 +4393,7 @@ version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
dependencies = [ dependencies = [
"nix", "nix 0.29.0",
"winapi", "winapi",
] ]
@@ -4523,6 +4595,18 @@ dependencies = [
"memoffset", "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]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@@ -5119,6 +5203,16 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "owned_ttf_parser" name = "owned_ttf_parser"
version = "0.25.1" version = "0.25.1"
@@ -5293,6 +5387,17 @@ dependencies = [
"pest", "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]] [[package]]
name = "pgp" name = "pgp"
version = "0.20.0" version = "0.20.0"
@@ -5603,6 +5708,12 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "qrcode"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.41.0" version = "0.41.0"
@@ -6069,6 +6180,16 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" 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]] [[package]]
name = "rsa" name = "rsa"
version = "0.9.10" version = "0.9.10"
@@ -6992,13 +7113,13 @@ dependencies = [
"fancy-regex", "fancy-regex",
"filedescriptor", "filedescriptor",
"finl_unicode", "finl_unicode",
"fixedbitset", "fixedbitset 0.4.2",
"hex", "hex",
"lazy_static", "lazy_static",
"libc", "libc",
"log", "log",
"memmem", "memmem",
"nix", "nix 0.29.0",
"num-derive", "num-derive",
"num-traits", "num-traits",
"ordered-float 4.6.0", "ordered-float 4.6.0",
@@ -7347,6 +7468,17 @@ dependencies = [
"once_cell", "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]] [[package]]
name = "try-lock" name = "try-lock"
version = "0.2.5" version = "0.2.5"
@@ -8553,6 +8685,24 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" 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]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.3" version = "0.6.3"

View File

@@ -15,10 +15,12 @@ rust-version = "1.92"
[workspace.dependencies] [workspace.dependencies]
apple-native-keyring-store = { version = "1.0", default-features = false, features = ["keychain", "protected"] } 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-std = "4.0"
cap-tempfile = "4.0" cap-tempfile = "4.0"
clap = { version = "4.6", features = ["derive"] } clap = { version = "4.6", features = ["derive"] }
crossterm = "0.29" crossterm = "0.29"
ctrlc = "3.5"
flate2 = "1.1" flate2 = "1.1"
gix = { version = "0.86", default-features = false, features = ["blocking-http-transport-reqwest-rust-tls", "index", "merge", "revision", "sha1", "tree-editor"] } gix = { version = "0.86", default-features = false, features = ["blocking-http-transport-reqwest-rust-tls", "index", "merge", "revision", "sha1", "tree-editor"] }
gix-config = "0.59" gix-config = "0.59"
@@ -26,9 +28,11 @@ iced = "0.14"
ironstorage = { path = "crates/storage" } ironstorage = { path = "crates/storage" }
keyring-core = "1.0" keyring-core = "1.0"
pgp = { version = "0.20", default-features = false } pgp = { version = "0.20", default-features = false }
qrcode = { version = "0.14", default-features = false }
rand = "0.8" rand = "0.8"
regex = "1.13" regex = "1.13"
reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] } 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"] } ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] }
security-framework = "3.7" security-framework = "3.7"
secret-service = { version = "5.1", default-features = false, features = ["rt-tokio-crypto-rust"] } secret-service = { version = "5.1", default-features = false, features = ["rt-tokio-crypto-rust"] }

View File

@@ -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. | | 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. | | 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`. | | 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. | | 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 With this path, the central crate needs no third-party native GPG, Git, OTP, or

View File

@@ -31,6 +31,8 @@ documented in [`docs/git-synchronization.md`](docs/git-synchronization.md).
Native credential storage, opaque secret references, user-presence policy, and Native credential storage, opaque secret references, user-presence policy, and
bounded caching are documented in bounded caching are documented in
[`docs/secure-secret-storage.md`](docs/secure-secret-storage.md). [`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 The capability-scoped password-store layout and atomic mutation guarantees are
documented in [`docs/repository-core.md`](docs/repository-core.md). documented in [`docs/repository-core.md`](docs/repository-core.md).
The embedded OpenPGP backend, exported-key model, secret-provider boundary, and The embedded OpenPGP backend, exported-key model, secret-provider boundary, and

View File

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

View File

@@ -1,23 +1,42 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![deny(clippy::disallowed_types)] #![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::{ use ironstorage::{
command::{ command::{
CliAction, CommandRequest, EXIT_CONFIG, EXIT_FAILURE, EXIT_SUCCESS, EXIT_UNAVAILABLE, CliAction, CommandRequest, EXIT_CONFIG, EXIT_FAILURE, EXIT_SUCCESS, EXIT_UNAVAILABLE,
GitRequest, HelpTopic, OtpRequest, Presentation, help_text, otp_version_text, parse_from, GeneratedPresentation, GitRequest, HelpTopic, OtpRequest, help_text, otp_version_text,
version_text, parse_from, version_text,
}, },
config::Config, config::Config,
crypto::KeyStore, crypto::KeyStore,
generate::{GeneratorConfig, PasswordGenerator},
git::{GitIdentity, GitRepository}, git::{GitIdentity, GitRepository},
read::{ShowOutput, ShowResult, VaultReader}, presentation::{
repository::Repository, ClipboardError, ClipboardTimeout, ClipboardWait, NativeClipboardManager, QrError, QrMatrix,
},
read::{PresentationChannel, ShowOutput, ShowResult, VaultReader},
repository::{Repository, SecretBytes},
secret_store::{ secret_store::{
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStore, NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStore,
SecretStoreBackend, SecretStoreBackend,
}, },
write::{
EntryCommit, EntryCommitError, EntryCommitter, NoGitEntryCommitter, OverwriteDecision,
},
}; };
#[allow(dead_code)] #[allow(dead_code)]
@@ -120,7 +139,9 @@ where
fn needs_secret_store(request: &CommandRequest) -> bool { fn needs_secret_store(request: &CommandRequest) -> bool {
matches!( matches!(
request, 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>, secrets: &mut SecretStore<B>,
stdout: &mut O, stdout: &mut O,
stderr: &mut E, 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, ()> { ) -> Result<u8, ()> {
match request { match request {
CommandRequest::Show(request) if request.presentation == Presentation::Terminal => { CommandRequest::Show(request) => {
let repository = match Repository::open(config.vault()) { let repository = match Repository::open(config.vault()) {
Ok(repository) => repository, Ok(repository) => repository,
Err(error) => return operation_error(stderr, error), Err(error) => return operation_error(stderr, error),
@@ -152,7 +191,71 @@ fn execute_secure<B: SecretStoreBackend, O: Write, E: Write>(
.map_err(|_| ())?; .map_err(|_| ())?;
Ok(EXIT_SUCCESS) 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), 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>( fn select_remote<'a>(
config: &'a Config, config: &'a Config,
requested: Option<&str>, requested: Option<&str>,
@@ -215,11 +461,12 @@ mod tests {
use ironstorage::{ use ironstorage::{
command::{ command::{
CommandRequest, EXIT_CONFIG, EXIT_SUCCESS, EXIT_UNAVAILABLE, EXIT_USAGE, Presentation, CommandRequest, EXIT_CONFIG, EXIT_SUCCESS, EXIT_UNAVAILABLE, EXIT_USAGE,
ShowRequest, GenerateRequest, GeneratedPresentation, Presentation, ShowRequest,
}, },
config::Config, config::Config,
git::GitCredentialProvider as _, git::GitCredentialProvider as _,
presentation::{ClipboardTimeout, QrMatrix},
repository::SecretBytes, repository::SecretBytes,
secret_store::{ secret_store::{
SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy, 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>>; type TestResult = Result<(), Box<dyn Error>>;
#[derive(Clone, Default)] #[derive(Clone, Default)]
struct MemoryBackend(Arc<Mutex<BTreeMap<SecretLocator, SecretBytes>>>); 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 { impl SecretStoreBackend for MemoryBackend {
fn create( fn create(
&self, &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] #[test]
fn help_and_usage_errors_have_stable_streams_and_exit_codes() -> TestResult { fn help_and_usage_errors_have_stable_streams_and_exit_codes() -> TestResult {
let mut stdout = Vec::new(); let mut stdout = Vec::new();
@@ -424,4 +724,149 @@ mod tests {
assert_eq!(credential.password(), b"fixture-token"); assert_eq!(credential.password(), b"fixture-token");
Ok(()) 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)
}
} }

View File

@@ -15,6 +15,7 @@ gix.workspace = true
gix-config.workspace = true gix-config.workspace = true
keyring-core.workspace = true keyring-core.workspace = true
pgp.workspace = true pgp.workspace = true
qrcode.workspace = true
rand.workspace = true rand.workspace = true
regex.workspace = true regex.workspace = true
reqwest.workspace = true reqwest.workspace = true
@@ -33,12 +34,17 @@ security-framework.workspace = true
windows-native-keyring-store.workspace = true windows-native-keyring-store.workspace = true
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
arboard.workspace = true
secret-service.workspace = true secret-service.workspace = true
zbus-secret-service-keyring-store.workspace = true zbus-secret-service-keyring-store.workspace = true
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
arboard.workspace = true
[dev-dependencies] [dev-dependencies]
hex = "0.4" hex = "0.4"
rand_chacha = "0.3" rand_chacha = "0.3"
rqrr.workspace = true
rustix = { version = "1.1", features = ["fs"] } rustix = { version = "1.1", features = ["fs"] }
sha2 = "0.10" sha2 = "0.10"
smallvec = "1.15" smallvec = "1.15"

View File

@@ -6,11 +6,14 @@ use std::{
error::Error, error::Error,
fmt, fs, fmt, fs,
path::{Component, Path, PathBuf}, path::{Component, Path, PathBuf},
time::Duration,
}; };
use serde::Deserialize; use serde::Deserialize;
use url::Url; use url::Url;
use crate::presentation::{ClipboardTimeout, DEFAULT_CLIPBOARD_TIMEOUT};
const APPLICATION_DIRECTORY: &str = "ironstorage"; const APPLICATION_DIRECTORY: &str = "ironstorage";
const CONFIG_FILE: &str = "config.toml"; const CONFIG_FILE: &str = "config.toml";
const MAX_CONFIG_BYTES: u64 = 1024 * 1024; const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
@@ -23,6 +26,7 @@ pub struct Config {
default_key: KeyIdentity, default_key: KeyIdentity,
key_material: PathBuf, key_material: PathBuf,
editor: Option<EditorCommand>, editor: Option<EditorCommand>,
clipboard_timeout: ClipboardTimeout,
git_remotes: Vec<GitRemote>, git_remotes: Vec<GitRemote>,
} }
@@ -52,6 +56,10 @@ impl Config {
self.editor.as_ref() self.editor.as_ref()
} }
pub fn clipboard_timeout(&self) -> ClipboardTimeout {
self.clipboard_timeout
}
pub fn git_remotes(&self) -> &[GitRemote] { pub fn git_remotes(&self) -> &[GitRemote] {
&self.git_remotes &self.git_remotes
} }
@@ -400,6 +408,7 @@ struct RawConfig {
default_key: Option<String>, default_key: Option<String>,
key_material: Option<PathBuf>, key_material: Option<PathBuf>,
editor: Option<RawEditor>, editor: Option<RawEditor>,
clipboard_timeout_seconds: Option<u64>,
#[serde(default)] #[serde(default)]
git: RawGit, git: RawGit,
} }
@@ -457,6 +466,13 @@ fn validate_config(source: PathBuf, raw: RawConfig) -> Result<Config, ConfigErro
}) })
.and_then(validate_key_identity)?; .and_then(validate_key_identity)?;
let editor = raw.editor.map(validate_editor).transpose()?; let editor = raw.editor.map(validate_editor).transpose()?;
let clipboard_timeout = ClipboardTimeout::new(Duration::from_secs(
raw.clipboard_timeout_seconds
.unwrap_or(DEFAULT_CLIPBOARD_TIMEOUT.as_secs()),
))
.map_err(|_| ConfigError::InvalidField {
field: "clipboard_timeout_seconds",
})?;
let git_remotes = validate_remotes(raw.git.remotes)?; let git_remotes = validate_remotes(raw.git.remotes)?;
Ok(Config { Ok(Config {
@@ -465,6 +481,7 @@ fn validate_config(source: PathBuf, raw: RawConfig) -> Result<Config, ConfigErro
default_key, default_key,
key_material, key_material,
editor, editor,
clipboard_timeout,
git_remotes, git_remotes,
}) })
} }
@@ -601,7 +618,14 @@ fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), Confi
validate_table( validate_table(
root, 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 { let Some(git) = root.get("git") else {
return Ok(()); return Ok(());

View File

@@ -11,6 +11,7 @@ pub mod crypto;
pub mod generate; pub mod generate;
pub mod git; pub mod git;
pub mod mutation; pub mod mutation;
pub mod presentation;
pub mod read; pub mod read;
pub mod recipient; pub mod recipient;
pub mod repository; pub mod repository;

View File

@@ -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<Self, ClipboardError> {
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<u8>) -> 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<ClipboardContent, ClipboardError>;
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<B> {
backend: B,
timeout: ClipboardTimeout,
}
impl<B> fmt::Debug for ClipboardManager<B> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ClipboardManager")
.field("timeout", &self.timeout)
.field("contents", &"[REDACTED]")
.finish()
}
}
impl<B: ClipboardBackend> ClipboardManager<B> {
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<ClipboardDisposition, ClipboardError> {
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<ClipboardDisposition, ClipboardError> {
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<platform::NativeClipboardBackend>;
impl NativeClipboardManager {
pub fn system(timeout: ClipboardTimeout) -> Result<Self, ClipboardError> {
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<u8>,
}
impl QrMatrix {
pub fn encode(payload: &SecretBytes) -> Result<Self, QrError> {
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<bool> {
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();
}
}

View File

@@ -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<Self, ClipboardError> {
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<ClipboardContent, ClipboardError> {
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<Self, ClipboardError> {
Err(ClipboardError::Unavailable)
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
impl ClipboardBackend for NativeClipboardBackend {
fn read(&mut self) -> Result<ClipboardContent, ClipboardError> {
Err(ClipboardError::Unavailable)
}
fn write(&mut self, _value: &SecretBytes) -> Result<(), ClipboardError> {
Err(ClipboardError::Unavailable)
}
fn clear(&mut self) -> Result<(), ClipboardError> {
Err(ClipboardError::Unavailable)
}
}

View File

@@ -1,8 +1,9 @@
#![forbid(unsafe_code)] #![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::config::{ConfigError, ConfigLoader, EditorSource};
use ironstorage::presentation::DEFAULT_CLIPBOARD_TIMEOUT;
use tempfile::TempDir; use tempfile::TempDir;
type TestResult = Result<(), Box<dyn Error>>; type TestResult = Result<(), Box<dyn Error>>;
@@ -78,6 +79,43 @@ fn explicit_relative_configuration_resolves_deterministically() -> TestResult {
); );
assert_eq!(remote.server_id().as_str(), "personal-git"); assert_eq!(remote.server_id().as_str(), "personal-git");
assert_eq!(remote.application_id().as_str(), "ironstorage-cli"); 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(()) Ok(())
} }

View File

@@ -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<dyn Error>>;
#[derive(Default)]
struct MemoryState {
text: Option<Vec<u8>>,
fail_read: bool,
fail_write: bool,
fail_clear: bool,
}
#[derive(Clone, Default)]
struct MemoryClipboard(Arc<Mutex<MemoryState>>);
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<Vec<u8>> {
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<ClipboardContent, ClipboardError> {
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(())
}

View File

@@ -26,6 +26,9 @@ key_material = "keys"
# accepted and split without launching a shell. # accepted and split without launching a shell.
editor = ["code", "--wait"] editor = ["code", "--wait"]
# Optional; upstream pass defaults to 45 seconds. Values are limited to 1..300.
clipboard_timeout_seconds = 45
[[git.remotes]] [[git.remotes]]
name = "origin" name = "origin"
url = "https://git.example.test/alice/password-store.git" 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 The HTTPS account name is stored inside the protected credential record, not in
TOML. OpenPGP passphrases are addressed by the resolved primary fingerprint. 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 Passwords, passphrases, tokens, credentials, private keys, and other secret
values are forbidden in TOML. Unknown fields are rejected. Parse errors never 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 echo the source line or value, so an accidentally supplied secret is not

View File

@@ -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 insert. `--in-place` requires an existing entry, decrypts it, replaces only the
bytes before its first newline, and preserves that newline and every following bytes before its first newline, and preserves that newline and every following
byte exactly. The generated password is returned separately as redacted, 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 The completed entry is encrypted for the nearest recipient policy and written
atomically. Embedded Git receives `Add generated password for ...` only after a atomically. Embedded Git receives `Add generated password for ...` only after a

46
docs/presentation.md Normal file
View File

@@ -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.

View File

@@ -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 `PresentationSecret` containing the logical entry, line, channel, and redacted
zeroizing contents. The adapter never parses rendered terminal output, and a zeroizing contents. The adapter never parses rendered terminal output, and a
missing or empty requested line is an explicit failure. 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 ## Name and plaintext search