Implement clipboard and QR presentation
This commit is contained in:
156
Cargo.lock
generated
156
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,6 +11,7 @@ name = "ironstorage"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
ctrlc.workspace = true
|
||||
ironstorage.workspace = true
|
||||
tempfile = "3"
|
||||
|
||||
|
||||
@@ -1,23 +1,42 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::disallowed_types)]
|
||||
|
||||
use std::{ffi::OsString, io::Write, process::ExitCode};
|
||||
use std::{
|
||||
error::Error,
|
||||
ffi::OsString,
|
||||
fmt,
|
||||
io::Write,
|
||||
path::Path,
|
||||
process::ExitCode,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ironstorage::{
|
||||
command::{
|
||||
CliAction, CommandRequest, EXIT_CONFIG, EXIT_FAILURE, EXIT_SUCCESS, EXIT_UNAVAILABLE,
|
||||
GitRequest, HelpTopic, OtpRequest, Presentation, help_text, otp_version_text, parse_from,
|
||||
version_text,
|
||||
GeneratedPresentation, GitRequest, HelpTopic, OtpRequest, help_text, otp_version_text,
|
||||
parse_from, version_text,
|
||||
},
|
||||
config::Config,
|
||||
crypto::KeyStore,
|
||||
generate::{GeneratorConfig, PasswordGenerator},
|
||||
git::{GitIdentity, GitRepository},
|
||||
read::{ShowOutput, ShowResult, VaultReader},
|
||||
repository::Repository,
|
||||
presentation::{
|
||||
ClipboardError, ClipboardTimeout, ClipboardWait, NativeClipboardManager, QrError, QrMatrix,
|
||||
},
|
||||
read::{PresentationChannel, ShowOutput, ShowResult, VaultReader},
|
||||
repository::{Repository, SecretBytes},
|
||||
secret_store::{
|
||||
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStore,
|
||||
SecretStoreBackend,
|
||||
},
|
||||
write::{
|
||||
EntryCommit, EntryCommitError, EntryCommitter, NoGitEntryCommitter, OverwriteDecision,
|
||||
},
|
||||
};
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -120,7 +139,9 @@ where
|
||||
fn needs_secret_store(request: &CommandRequest) -> bool {
|
||||
matches!(
|
||||
request,
|
||||
CommandRequest::Show(_) | CommandRequest::Git(GitRequest::Fetch { .. })
|
||||
CommandRequest::Show(_)
|
||||
| CommandRequest::Generate(_)
|
||||
| CommandRequest::Git(GitRequest::Fetch { .. })
|
||||
)
|
||||
}
|
||||
|
||||
@@ -130,9 +151,27 @@ fn execute_secure<B: SecretStoreBackend, O: Write, E: Write>(
|
||||
secrets: &mut SecretStore<B>,
|
||||
stdout: &mut O,
|
||||
stderr: &mut E,
|
||||
) -> Result<u8, ()> {
|
||||
execute_secure_with(
|
||||
config,
|
||||
request,
|
||||
secrets,
|
||||
&mut NativePresentation,
|
||||
stdout,
|
||||
stderr,
|
||||
)
|
||||
}
|
||||
|
||||
fn execute_secure_with<B: SecretStoreBackend, P: CliPresentation, O: Write, E: Write>(
|
||||
config: &Config,
|
||||
request: &CommandRequest,
|
||||
secrets: &mut SecretStore<B>,
|
||||
presentation: &mut P,
|
||||
stdout: &mut O,
|
||||
stderr: &mut E,
|
||||
) -> Result<u8, ()> {
|
||||
match request {
|
||||
CommandRequest::Show(request) if request.presentation == Presentation::Terminal => {
|
||||
CommandRequest::Show(request) => {
|
||||
let repository = match Repository::open(config.vault()) {
|
||||
Ok(repository) => repository,
|
||||
Err(error) => return operation_error(stderr, error),
|
||||
@@ -152,7 +191,71 @@ fn execute_secure<B: SecretStoreBackend, O: Write, E: Write>(
|
||||
.map_err(|_| ())?;
|
||||
Ok(EXIT_SUCCESS)
|
||||
}
|
||||
Ok(ShowOutput::Present(_)) => Ok(EXIT_UNAVAILABLE),
|
||||
Ok(ShowOutput::Present(secret)) => {
|
||||
let description = format!("{} line {}", secret.entry(), secret.line());
|
||||
let result = match secret.channel() {
|
||||
PresentationChannel::Clipboard => presentation.clipboard(
|
||||
secret.contents(),
|
||||
config.clipboard_timeout(),
|
||||
&description,
|
||||
stdout,
|
||||
),
|
||||
PresentationChannel::QrCode => {
|
||||
presentation.qr_code(secret.contents(), stdout)
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(()) => Ok(EXIT_SUCCESS),
|
||||
Err(error) => operation_error(stderr, error),
|
||||
}
|
||||
}
|
||||
Err(error) => operation_error(stderr, error),
|
||||
}
|
||||
}
|
||||
CommandRequest::Generate(request) => {
|
||||
let repository = match Repository::open(config.vault()) {
|
||||
Ok(repository) => repository,
|
||||
Err(error) => return operation_error(stderr, error),
|
||||
};
|
||||
let keys = match KeyStore::load(config.key_material()) {
|
||||
Ok(keys) => keys,
|
||||
Err(error) => return operation_error(stderr, error),
|
||||
};
|
||||
let mut committer = match generation_committer(&repository, &request.entry) {
|
||||
Ok(committer) => committer,
|
||||
Err(error) => return operation_error(stderr, error),
|
||||
};
|
||||
let outcome =
|
||||
match PasswordGenerator::new(&repository, &keys, GeneratorConfig::pass_defaults())
|
||||
.generate(
|
||||
request,
|
||||
if request.force || request.in_place {
|
||||
OverwriteDecision::Allow
|
||||
} else {
|
||||
OverwriteDecision::Decline
|
||||
},
|
||||
None,
|
||||
secrets,
|
||||
&mut committer,
|
||||
) {
|
||||
Ok(outcome) => outcome,
|
||||
Err(error) => return operation_error(stderr, error),
|
||||
};
|
||||
let result = match request.presentation {
|
||||
GeneratedPresentation::Terminal => stdout
|
||||
.write_all(outcome.password().expose())
|
||||
.and_then(|()| stdout.write_all(b"\n"))
|
||||
.map_err(|_| PresentationFailure::Output),
|
||||
GeneratedPresentation::Clipboard => presentation.clipboard(
|
||||
outcome.password(),
|
||||
config.clipboard_timeout(),
|
||||
&format!("generated password for {}", request.entry),
|
||||
stdout,
|
||||
),
|
||||
GeneratedPresentation::QrCode => presentation.qr_code(outcome.password(), stdout),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => Ok(EXIT_SUCCESS),
|
||||
Err(error) => operation_error(stderr, error),
|
||||
}
|
||||
}
|
||||
@@ -185,6 +288,149 @@ fn execute_secure<B: SecretStoreBackend, O: Write, E: Write>(
|
||||
}
|
||||
}
|
||||
|
||||
enum GenerationCommitter {
|
||||
Git(Box<GitRepository>),
|
||||
None(NoGitEntryCommitter),
|
||||
}
|
||||
|
||||
impl EntryCommitter for GenerationCommitter {
|
||||
fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> {
|
||||
match self {
|
||||
Self::Git(git) => EntryCommitter::commit(git.as_mut(), change),
|
||||
Self::None(committer) => committer.commit(change),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generation_committer(
|
||||
repository: &Repository,
|
||||
entry: &str,
|
||||
) -> Result<GenerationCommitter, ironstorage::git::GitError> {
|
||||
let identity = GitIdentity::new("IronStorage", "ironstorage@localhost")
|
||||
.expect("the built-in Git identity is valid");
|
||||
match GitRepository::open_innermost(repository, Path::new(entry), identity) {
|
||||
Ok(git) => Ok(GenerationCommitter::Git(Box::new(git))),
|
||||
Err(ironstorage::git::GitError::NotRepository) => {
|
||||
Ok(GenerationCommitter::None(NoGitEntryCommitter))
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
trait CliPresentation {
|
||||
fn clipboard(
|
||||
&mut self,
|
||||
value: &SecretBytes,
|
||||
timeout: ClipboardTimeout,
|
||||
description: &str,
|
||||
stdout: &mut dyn Write,
|
||||
) -> Result<(), PresentationFailure>;
|
||||
|
||||
fn qr_code(
|
||||
&mut self,
|
||||
value: &SecretBytes,
|
||||
stdout: &mut dyn Write,
|
||||
) -> Result<(), PresentationFailure>;
|
||||
}
|
||||
|
||||
struct NativePresentation;
|
||||
|
||||
impl CliPresentation for NativePresentation {
|
||||
fn clipboard(
|
||||
&mut self,
|
||||
value: &SecretBytes,
|
||||
timeout: ClipboardTimeout,
|
||||
description: &str,
|
||||
stdout: &mut dyn Write,
|
||||
) -> Result<(), PresentationFailure> {
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
let signal = Arc::clone(&cancelled);
|
||||
ctrlc::set_handler(move || signal.store(true, Ordering::SeqCst))
|
||||
.map_err(|_| PresentationFailure::CancellationHandler)?;
|
||||
let mut clipboard = NativeClipboardManager::system(timeout)?;
|
||||
let mut output_failed = false;
|
||||
let result = clipboard.copy_with(value, |duration| {
|
||||
if writeln!(
|
||||
stdout,
|
||||
"Copied {description} to clipboard. Will restore or clear in {} seconds.",
|
||||
duration.as_secs()
|
||||
)
|
||||
.and_then(|()| stdout.flush())
|
||||
.is_err()
|
||||
{
|
||||
output_failed = true;
|
||||
return ClipboardWait::Cancelled;
|
||||
}
|
||||
wait_for_clipboard(duration, &cancelled)
|
||||
});
|
||||
if output_failed {
|
||||
return Err(PresentationFailure::Output);
|
||||
}
|
||||
result.map(|_| ()).map_err(Into::into)
|
||||
}
|
||||
|
||||
fn qr_code(
|
||||
&mut self,
|
||||
value: &SecretBytes,
|
||||
stdout: &mut dyn Write,
|
||||
) -> Result<(), PresentationFailure> {
|
||||
let matrix = QrMatrix::encode(value)?;
|
||||
let rendered = matrix.render_terminal();
|
||||
stdout
|
||||
.write_all(rendered.expose())
|
||||
.map_err(|_| PresentationFailure::Output)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum PresentationFailure {
|
||||
Clipboard(ClipboardError),
|
||||
Qr(QrError),
|
||||
CancellationHandler,
|
||||
Output,
|
||||
}
|
||||
|
||||
impl fmt::Display for PresentationFailure {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Clipboard(error) => error.fmt(formatter),
|
||||
Self::Qr(error) => error.fmt(formatter),
|
||||
Self::CancellationHandler => {
|
||||
formatter.write_str("clipboard cancellation handling is unavailable")
|
||||
}
|
||||
Self::Output => formatter.write_str("presentation output could not be written"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_clipboard(duration: Duration, cancelled: &AtomicBool) -> ClipboardWait {
|
||||
let deadline = Instant::now() + duration;
|
||||
loop {
|
||||
if cancelled.load(Ordering::SeqCst) {
|
||||
return ClipboardWait::Cancelled;
|
||||
}
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return ClipboardWait::Elapsed;
|
||||
}
|
||||
std::thread::sleep(remaining.min(Duration::from_millis(50)));
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for PresentationFailure {}
|
||||
|
||||
impl From<ClipboardError> for PresentationFailure {
|
||||
fn from(error: ClipboardError) -> Self {
|
||||
Self::Clipboard(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<QrError> for PresentationFailure {
|
||||
fn from(error: QrError) -> Self {
|
||||
Self::Qr(error)
|
||||
}
|
||||
}
|
||||
|
||||
fn select_remote<'a>(
|
||||
config: &'a Config,
|
||||
requested: Option<&str>,
|
||||
@@ -215,11 +461,12 @@ mod tests {
|
||||
|
||||
use ironstorage::{
|
||||
command::{
|
||||
CommandRequest, EXIT_CONFIG, EXIT_SUCCESS, EXIT_UNAVAILABLE, EXIT_USAGE, Presentation,
|
||||
ShowRequest,
|
||||
CommandRequest, EXIT_CONFIG, EXIT_SUCCESS, EXIT_UNAVAILABLE, EXIT_USAGE,
|
||||
GenerateRequest, GeneratedPresentation, Presentation, ShowRequest,
|
||||
},
|
||||
config::Config,
|
||||
git::GitCredentialProvider as _,
|
||||
presentation::{ClipboardTimeout, QrMatrix},
|
||||
repository::SecretBytes,
|
||||
secret_store::{
|
||||
SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy,
|
||||
@@ -227,13 +474,52 @@ mod tests {
|
||||
},
|
||||
};
|
||||
|
||||
use super::{execute_secure, run_with};
|
||||
use super::{
|
||||
CliPresentation, PresentationFailure, execute_secure, execute_secure_with, run_with,
|
||||
wait_for_clipboard,
|
||||
};
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error>>;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct MemoryBackend(Arc<Mutex<BTreeMap<SecretLocator, SecretBytes>>>);
|
||||
|
||||
#[derive(Default)]
|
||||
struct MemoryPresentation {
|
||||
clipboard: Vec<Vec<u8>>,
|
||||
qr: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl CliPresentation for MemoryPresentation {
|
||||
fn clipboard(
|
||||
&mut self,
|
||||
value: &SecretBytes,
|
||||
timeout: ClipboardTimeout,
|
||||
description: &str,
|
||||
stdout: &mut dyn std::io::Write,
|
||||
) -> Result<(), PresentationFailure> {
|
||||
self.clipboard.push(value.expose().to_vec());
|
||||
writeln!(
|
||||
stdout,
|
||||
"Copied {description} for {} seconds.",
|
||||
timeout.duration().as_secs()
|
||||
)
|
||||
.map_err(|_| PresentationFailure::Output)
|
||||
}
|
||||
|
||||
fn qr_code(
|
||||
&mut self,
|
||||
value: &SecretBytes,
|
||||
stdout: &mut dyn std::io::Write,
|
||||
) -> Result<(), PresentationFailure> {
|
||||
self.qr.push(value.expose().to_vec());
|
||||
let matrix = QrMatrix::encode(value)?;
|
||||
stdout
|
||||
.write_all(matrix.render_terminal().expose())
|
||||
.map_err(|_| PresentationFailure::Output)
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretStoreBackend for MemoryBackend {
|
||||
fn create(
|
||||
&self,
|
||||
@@ -288,6 +574,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_wait_is_interruptible_without_skipping_storage_cleanup() {
|
||||
let cancelled = std::sync::atomic::AtomicBool::new(true);
|
||||
assert_eq!(
|
||||
wait_for_clipboard(std::time::Duration::from_secs(60), &cancelled),
|
||||
ironstorage::presentation::ClipboardWait::Cancelled
|
||||
);
|
||||
let elapsed = std::sync::atomic::AtomicBool::new(false);
|
||||
assert_eq!(
|
||||
wait_for_clipboard(std::time::Duration::from_millis(1), &elapsed),
|
||||
ironstorage::presentation::ClipboardWait::Elapsed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_and_usage_errors_have_stable_streams_and_exit_codes() -> TestResult {
|
||||
let mut stdout = Vec::new();
|
||||
@@ -424,4 +724,149 @@ mod tests {
|
||||
assert_eq!(credential.password(), b"fixture-token");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_clipboard_and_qr_paths_never_emit_plaintext() -> TestResult {
|
||||
const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30";
|
||||
let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../crates/storage/tests/fixtures/compatibility");
|
||||
let temporary = tempfile::tempdir()?;
|
||||
let config_path = temporary.path().join("config.toml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
format!(
|
||||
"vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\nclipboard_timeout_seconds = 1\n",
|
||||
fixtures.join("stores/basic"),
|
||||
FINGERPRINT,
|
||||
fixtures.join("keys"),
|
||||
),
|
||||
)?;
|
||||
let config = Config::load(Some(&config_path))?;
|
||||
let mut secrets = fixture_secrets(FINGERPRINT)?;
|
||||
let mut presentation = MemoryPresentation::default();
|
||||
|
||||
for channel in [
|
||||
Presentation::Clipboard {
|
||||
line: std::num::NonZeroUsize::new(1).expect("non-zero"),
|
||||
},
|
||||
Presentation::QrCode {
|
||||
line: std::num::NonZeroUsize::new(1).expect("non-zero"),
|
||||
},
|
||||
] {
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
assert_eq!(
|
||||
execute_secure_with(
|
||||
&config,
|
||||
&CommandRequest::Show(ShowRequest {
|
||||
entry: Some("email/personal".to_owned()),
|
||||
presentation: channel,
|
||||
}),
|
||||
&mut secrets,
|
||||
&mut presentation,
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
)
|
||||
.expect("memory output cannot fail"),
|
||||
EXIT_SUCCESS
|
||||
);
|
||||
assert!(
|
||||
!stdout
|
||||
.windows(b"correct horse fixture".len())
|
||||
.any(|part| { part == b"correct horse fixture" })
|
||||
);
|
||||
assert!(stderr.is_empty());
|
||||
}
|
||||
assert_eq!(
|
||||
presentation.clipboard,
|
||||
vec![b"correct horse fixture".to_vec()]
|
||||
);
|
||||
assert_eq!(presentation.qr, vec![b"correct horse fixture".to_vec()]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_generated_passwords_use_clipboard_and_qr_without_plaintext_output() -> TestResult {
|
||||
const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30";
|
||||
let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../crates/storage/tests/fixtures/compatibility");
|
||||
let temporary = tempfile::tempdir()?;
|
||||
let vault = temporary.path().join("vault");
|
||||
fs::create_dir(&vault)?;
|
||||
fs::copy(fixtures.join("stores/basic/.gpg-id"), vault.join(".gpg-id"))?;
|
||||
fs::copy(
|
||||
fixtures.join("stores/basic/.gpg-id.sig"),
|
||||
vault.join(".gpg-id.sig"),
|
||||
)?;
|
||||
let config_path = temporary.path().join("config.toml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
format!(
|
||||
"vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\nclipboard_timeout_seconds = 1\n",
|
||||
vault,
|
||||
FINGERPRINT,
|
||||
fixtures.join("keys"),
|
||||
),
|
||||
)?;
|
||||
let config = Config::load(Some(&config_path))?;
|
||||
let mut secrets = fixture_secrets(FINGERPRINT)?;
|
||||
let mut presentation = MemoryPresentation::default();
|
||||
|
||||
for (entry, channel) in [
|
||||
("generated/clip", GeneratedPresentation::Clipboard),
|
||||
("generated/qr", GeneratedPresentation::QrCode),
|
||||
] {
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
assert_eq!(
|
||||
execute_secure_with(
|
||||
&config,
|
||||
&CommandRequest::Generate(GenerateRequest {
|
||||
entry: entry.to_owned(),
|
||||
length: std::num::NonZeroUsize::new(16),
|
||||
no_symbols: true,
|
||||
force: false,
|
||||
in_place: false,
|
||||
presentation: channel,
|
||||
}),
|
||||
&mut secrets,
|
||||
&mut presentation,
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
)
|
||||
.expect("memory output cannot fail"),
|
||||
EXIT_SUCCESS
|
||||
);
|
||||
let presented = match channel {
|
||||
GeneratedPresentation::Clipboard => presentation.clipboard.last(),
|
||||
GeneratedPresentation::QrCode => presentation.qr.last(),
|
||||
GeneratedPresentation::Terminal => None,
|
||||
}
|
||||
.expect("generated secret was presented");
|
||||
assert_eq!(presented.len(), 16);
|
||||
assert!(presented.iter().all(u8::is_ascii_alphanumeric));
|
||||
assert!(
|
||||
!stdout
|
||||
.windows(presented.len())
|
||||
.any(|part| part == presented)
|
||||
);
|
||||
assert!(stderr.is_empty());
|
||||
assert!(vault.join(format!("{entry}.gpg")).is_file());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fixture_secrets(fingerprint: &str) -> Result<SecretStore<MemoryBackend>, SecretStoreError> {
|
||||
let secrets = SecretStore::new(
|
||||
MemoryBackend::default(),
|
||||
SecretCachePolicy::Disabled,
|
||||
SecretProtectionPolicy::device_unlocked(),
|
||||
);
|
||||
secrets.unlock()?;
|
||||
secrets.create(
|
||||
&SecretReference::openpgp_passphrase(fingerprint)?,
|
||||
SecretBytes::new(b"fixture-alice-passphrase".to_vec()),
|
||||
)?;
|
||||
Ok(secrets)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<EditorCommand>,
|
||||
clipboard_timeout: ClipboardTimeout,
|
||||
git_remotes: Vec<GitRemote>,
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
key_material: Option<PathBuf>,
|
||||
editor: Option<RawEditor>,
|
||||
clipboard_timeout_seconds: Option<u64>,
|
||||
#[serde(default)]
|
||||
git: RawGit,
|
||||
}
|
||||
@@ -457,6 +466,13 @@ fn validate_config(source: PathBuf, raw: RawConfig) -> Result<Config, ConfigErro
|
||||
})
|
||||
.and_then(validate_key_identity)?;
|
||||
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)?;
|
||||
|
||||
Ok(Config {
|
||||
@@ -465,6 +481,7 @@ fn validate_config(source: PathBuf, raw: RawConfig) -> Result<Config, ConfigErro
|
||||
default_key,
|
||||
key_material,
|
||||
editor,
|
||||
clipboard_timeout,
|
||||
git_remotes,
|
||||
})
|
||||
}
|
||||
@@ -601,7 +618,14 @@ fn validate_known_fields(value: &toml::Value, source: &Path) -> 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(());
|
||||
|
||||
@@ -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;
|
||||
|
||||
301
crates/storage/src/presentation.rs
Normal file
301
crates/storage/src/presentation.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
85
crates/storage/src/presentation/platform.rs
Normal file
85
crates/storage/src/presentation/platform.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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<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.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(())
|
||||
}
|
||||
|
||||
|
||||
231
crates/storage/tests/presentation.rs
Normal file
231
crates/storage/tests/presentation.rs
Normal 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(())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
46
docs/presentation.md
Normal file
46
docs/presentation.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user