diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e475ca..39d191e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,40 @@ range may break in any release. ### Added +- **M5 (slice 2) — TUI reveal + clipboard copy.** The detail pane is no longer + secret-free: `Space` reveals the selected login's password on demand and + `c` / `u` / `o` copy the password / username / URI to the clipboard, with a + status-bar toast (`copied password · clears in 30s`) and a 30-second + auto-clear. Copy/reveal act only when the item list is focused. + - **Clipboard lives in the agent**, not the TUI. A new `Request::Copy { id, + name, field, clear_after_secs }` has the agent decrypt the field, place it on + its own clipboard (`arboard`, `wayland-data-control`), and schedule the + clear — so the secret never crosses the socket on the copy path, the copy + survives the TUI quitting, and a future `vault get --copy` becomes possible. + The auto-clear task only wipes the clipboard if it still holds what we wrote + (or can't read it back, failing safe), leaving anything the user copied since + untouched. Behind a default-on `clipboard` feature on `vault-agent`; a + `--no-default-features` headless build drops the X11/Wayland tree and answers + `Copy` with a clean "not compiled in" error. + - **Reveal uses `Request::Get`**, which gains an `id: Option` field. + The TUI targets the *exact* selected cipher id, closing a real footgun: + `get_item` matched by name only, so a duplicate item name could reveal/copy + the wrong item. Name remains the fallback selector and error label; the CLI + passes `id: None` (unchanged behavior). Revealed plaintext is held in a + `RevealedSecret` newtype (zeroised on drop, redacted in `Debug`) and + re-masked on any navigation. + - Tests: `vault-agent` gains an id-targeting-among-duplicate-names regression + test and a pure `should_clear_clipboard` unit; `vault-tui` adds reveal / + re-mask / toast / `Debug`-redaction units and `TestBackend` smokes for the + masked-by-default, revealed, and toast states. + - Supply-chain: `arboard` pulls `error-code` (`BSL-1.0`, via Windows-only + `clipboard-win`) — added to `deny.toml`'s allow-list (FSF-confirmed + GPL-compatible). No new advisories (`cargo deny check advisories` clean). + - Known limitation: on `Quit` / `stop-agent` a pending auto-clear task dies + with the runtime, so a just-copied secret can linger on the clipboard until + overwritten (notably under `wayland-data-control`'s serving process). A + clear-on-shutdown sweep is a tracked follow-up. + - **M5 (slice 1) — `vault-tui` skeleton (read-only browser).** The TUI stub is now a real cruxpass-style three-pane interface (`ratatui` + `crossterm`): **left** folder list, **center** filterable item list, **right** item detail, diff --git a/Cargo.lock b/Cargo.lock index 2d09edb..3a0a2e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,6 +84,24 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "argon2" version = "0.5.3" @@ -293,6 +311,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -457,6 +484,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -468,6 +505,12 @@ dependencies = [ "syn", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "either" version = "1.16.0" @@ -499,6 +542,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "fastrand" version = "2.4.1" @@ -511,6 +560,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "fnv" version = "1.0.7" @@ -640,6 +695,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1166,6 +1231,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -1182,6 +1256,79 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1200,6 +1347,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[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 = "parking_lot" version = "0.12.5" @@ -1257,12 +1414,29 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "potential_utf" version = "0.1.5" @@ -1306,6 +1480,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2092,6 +2275,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", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -2192,6 +2386,7 @@ name = "vault-agent" version = "0.0.1" dependencies = [ "anyhow", + "arboard", "clap", "serde_json", "tempfile", @@ -2308,6 +2503,7 @@ dependencies = [ "vault-core", "vault-ipc", "vault-theme", + "zeroize", ] [[package]] @@ -2438,6 +2634,76 @@ dependencies = [ "semver", ] +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.99" @@ -2834,12 +3100,47 @@ dependencies = [ "wasmparser", ] +[[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.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index b0dfb92..dac2082 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,11 @@ fs2 = "0.4" ratatui = "0.29" crossterm = "0.28" +# Clipboard — arboard (MIT/Apache-2.0; GPL-3.0-or-later compatible). Held by +# the agent for `vault` copy. `wayland-data-control` lets a copied secret +# persist after the writing process exits on wlroots compositors. +arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] } + tempfile = "3" ciborium = "0.2" ciborium-io = "0.2" diff --git a/crates/vault-agent/Cargo.toml b/crates/vault-agent/Cargo.toml index d9b8159..be9f16e 100644 --- a/crates/vault-agent/Cargo.toml +++ b/crates/vault-agent/Cargo.toml @@ -20,6 +20,14 @@ path = "src/main.rs" [lints] workspace = true +[features] +# `clipboard` (default) pulls arboard so `Request::Copy` can place secrets on +# the system clipboard. A headless install can drop it with +# `--no-default-features` to shed the X11/Wayland dependency tree; copy then +# returns a clean "not compiled in" error. +default = ["clipboard"] +clipboard = ["dep:arboard"] + [dependencies] anyhow = { workspace = true } tokio = { workspace = true } @@ -29,6 +37,7 @@ zeroize = { workspace = true } uuid = { workspace = true } url = { workspace = true } thiserror = { workspace = true } +arboard = { workspace = true, optional = true } vault-core = { path = "../vault-core" } vault-ipc = { path = "../vault-ipc" } vault-store = { path = "../vault-store" } diff --git a/crates/vault-agent/src/server.rs b/crates/vault-agent/src/server.rs index 4dd2d0c..ce62f84 100644 --- a/crates/vault-agent/src/server.rs +++ b/crates/vault-agent/src/server.rs @@ -125,10 +125,10 @@ async fn dispatch(req: Request, state: &Arc>) -> Response { Err(e) => Response::Error(e), } } - Request::Get { name, field } => { + Request::Get { id, name, field } => { let mut s = state.lock().await; let f = field.unwrap_or_default(); - let res = s.get_item(&name, f); + let res = s.get_item(id.as_deref(), &name, f); s.touch(); drop(s); match res { @@ -136,6 +136,39 @@ async fn dispatch(req: Request, state: &Arc>) -> Response { Err(e) => Response::Error(e), } } + #[cfg(feature = "clipboard")] + Request::Copy { + id, + name, + field, + clear_after_secs, + } => { + let f = field.unwrap_or_default(); + let mut s = state.lock().await; + // Decrypt the field, then hand it straight to the agent's own + // clipboard. `item` zeroises its copy on drop; `value` is the copy + // the clear task carries so it knows what to wipe. + let outcome = match s.get_item(id.as_deref(), &name, f) { + Ok(item) => { + let value = zeroize::Zeroizing::new(item.value.clone()); + s.clipboard_set(&value).map(|()| value) + } + Err(e) => Err(e), + }; + s.touch(); + drop(s); + match outcome { + Ok(value) => { + schedule_clipboard_clear(state.clone(), value, clear_after_secs); + Response::Ok + } + Err(e) => Response::Error(e), + } + } + #[cfg(not(feature = "clipboard"))] + Request::Copy { .. } => Response::Error(vault_ipc::proto::Error::Internal( + "clipboard support not compiled in".to_owned(), + )), Request::Remove { selector } => { // Hold the agent mutex across the network call. Vault is // single-user / single-agent, so request concurrency is low and @@ -214,6 +247,36 @@ async fn dispatch(req: Request, state: &Arc>) -> Response { } } +/// Default seconds before the agent wipes a copied secret from the clipboard. +/// +/// 30 s follows common password-manager practice (and Vault PRD §7.2): long +/// enough to paste, short enough to bound exposure. `Request::Copy` overrides +/// per call; config-driven tuning lands in a later slice. +#[cfg(feature = "clipboard")] +const DEFAULT_CLIPBOARD_CLEAR_SECS: u64 = 30; + +/// Spawn a one-shot task that wipes the clipboard after `clear_after_secs` (or +/// the default), but only if it still holds the value we copied. `Some(0)` +/// disables the auto-clear. The task carries the secret so the clear survives +/// the requesting client quitting. +#[cfg(feature = "clipboard")] +fn schedule_clipboard_clear( + state: Arc>, + value: zeroize::Zeroizing, + clear_after_secs: Option, +) { + use tokio::time::{Duration, sleep}; + let secs = clear_after_secs.unwrap_or(DEFAULT_CLIPBOARD_CLEAR_SECS); + if secs == 0 { + return; + } + tokio::spawn(async move { + sleep(Duration::from_secs(secs)).await; + let mut s = state.lock().await; + s.clipboard_clear_if_ours(&value); + }); +} + /// Optional periodic idle-lock task — caller spawns it after `run` starts. pub async fn idle_lock_loop(state: Arc>) { use tokio::time::{Duration, sleep}; @@ -273,6 +336,7 @@ mod tests { write_frame( &mut wr, &Request::Get { + id: None, name: "github.com".into(), field: Some(Field::Password), }, @@ -282,6 +346,24 @@ mod tests { let resp: Response = read_frame(&mut rd).await.unwrap(); assert!(matches!(resp, Response::Error(IpcError::Locked))); + // Copy-while-locked exercises the new dispatch arm. It must decline with + // an error before ever touching the clipboard (so it's deterministic on + // a headless CI box). With the clipboard feature it's `Locked`; without + // it's the "not compiled in" internal error — either way an error. + write_frame( + &mut wr, + &Request::Copy { + id: None, + name: "github.com".into(), + field: Some(Field::Password), + clear_after_secs: Some(0), + }, + ) + .await + .unwrap(); + let resp: Response = read_frame(&mut rd).await.unwrap(); + assert!(matches!(resp, Response::Error(_))); + write_frame(&mut wr, &Request::Quit).await.unwrap(); let resp: Response = read_frame(&mut rd).await.unwrap(); assert!(matches!(resp, Response::Ok)); diff --git a/crates/vault-agent/src/state.rs b/crates/vault-agent/src/state.rs index c0d9c8e..479b3dc 100644 --- a/crates/vault-agent/src/state.rs +++ b/crates/vault-agent/src/state.rs @@ -51,6 +51,12 @@ pub struct AgentState { pub idle_lock_secs: u64, /// Set by `Request::Quit` to ask the accept loop to exit cleanly. pub shutdown_requested: bool, + /// Clipboard handle for `Request::Copy`. `None` when no backend is + /// available (headless / init failed); copy requests then decline cleanly. + /// The handle must outlive its writes — on X11 the owning process serves + /// the selection — so it lives here for the agent's lifetime. + #[cfg(feature = "clipboard")] + pub clipboard: Option, } /// Plaintext field overlay for `add_cipher` / `edit_cipher`. Every field is @@ -84,6 +90,8 @@ impl AgentState { last_activity: Instant::now(), idle_lock_secs, shutdown_requested: false, + #[cfg(feature = "clipboard")] + clipboard: init_clipboard(), } } @@ -343,8 +351,13 @@ impl AgentState { Ok(()) } - /// Decrypt the named field on the cipher matching `query` (case-insensitive). - pub fn get_item(&self, query: &str, field: Field) -> Result { + /// Decrypt one `field` on a single cipher. + /// + /// When `id` is `Some`, the lookup targets that exact cipher id — the only + /// reliable path when several items share a name. When `id` is `None`, it + /// falls back to a case-insensitive match on `query` and returns the first + /// hit (the long-standing CLI behavior). `query` is also the error label. + pub fn get_item(&self, id: Option<&str>, query: &str, field: Field) -> Result { let v = self.vault.as_ref().ok_or(IpcError::Locked)?; let query_lower = query.to_lowercase(); let mut matched: Option<(&Cipher, String)> = None; @@ -352,10 +365,17 @@ impl AgentState { let name = c .decrypt_name(&v.user_enc, &v.user_mac) .map_err(|e| IpcError::Decrypt(e.to_string()))?; - if let Some(n) = name - && n.to_lowercase() == query_lower - { - matched = Some((c, n)); + let hit = id.map_or_else( + || { + name.as_deref() + .is_some_and(|n| n.to_lowercase() == query_lower) + }, + |want| c.id == want, + ); + if hit { + // Fall back to the query string for the display name on the + // rare cipher with no decryptable name. + matched = Some((c, name.unwrap_or_else(|| query.to_owned()))); break; } } @@ -405,6 +425,59 @@ impl AgentState { value, }) } + + /// Place `value` on the system clipboard. Errors if no backend is available + /// so the caller can report it instead of silently dropping the copy. + #[cfg(feature = "clipboard")] + pub fn clipboard_set(&mut self, value: &str) -> Result<(), IpcError> { + let cb = self + .clipboard + .as_mut() + .ok_or_else(|| IpcError::Internal("clipboard backend unavailable".to_owned()))?; + cb.set_text(value.to_owned()) + .map_err(|e| IpcError::Internal(format!("clipboard write failed: {e}"))) + } + + /// Clear the clipboard if it still holds `written` (the value we copied), or + /// if its contents can't be read. Leaves anything the user has since copied + /// untouched. Invoked by the scheduled auto-clear task; never errors. + #[cfg(feature = "clipboard")] + pub fn clipboard_clear_if_ours(&mut self, written: &str) { + let Some(cb) = self.clipboard.as_mut() else { + return; + }; + let current = cb.get_text().ok(); + if should_clear_clipboard(current.as_deref(), written) { + // Best-effort: a failed clear is no worse than the timer never + // having run; the next copy will overwrite regardless. + let _ = cb.clear(); + } + } +} + +/// Build a clipboard handle, degrading to `None` (with a warning) when no +/// backend is reachable — e.g. a headless server with no display. Copy requests +/// then return a clean error rather than the agent failing to start. +#[cfg(feature = "clipboard")] +fn init_clipboard() -> Option { + match arboard::Clipboard::new() { + Ok(cb) => Some(cb), + Err(e) => { + eprintln!("vault-agent: clipboard unavailable, copy will be declined: {e}"); + None + } + } +} + +/// Whether the auto-clear task should wipe the clipboard. +/// +/// `current` is the clipboard's present contents (`None` if it couldn't be +/// read). Clear when it still holds exactly what we wrote, and also when it +/// can't be read — failing safe so a secret is never stranded. When it holds +/// something else, the user (or another app) has replaced it, so leave it be. +#[cfg(feature = "clipboard")] +fn should_clear_clipboard(current: Option<&str>, written: &str) -> bool { + current.is_none_or(|c| c == written) } /// Decode an optional secret byte buffer into a `String`, zeroising the bytes @@ -541,11 +614,72 @@ mod tests { let s = AgentState::new(900); assert!(matches!(s.list_entries(), Err(IpcError::Locked))); assert!(matches!( - s.get_item("anything", Field::Password), + s.get_item(None, "anything", Field::Password), Err(IpcError::Locked) )); } + #[test] + fn get_item_targets_exact_id_among_duplicate_names() { + let enc = [11u8; 32]; + let mac = [12u8; 32]; + let mut v = stub_vault(); + v.user_enc = Zeroizing::new(enc); + v.user_mac = Zeroizing::new(mac); + v.ciphers.push(login_with_password( + "id-first", + "dup", + "first-secret", + &enc, + &mac, + )); + v.ciphers.push(login_with_password( + "id-second", + "dup", + "second-secret", + &enc, + &mac, + )); + let mut s = AgentState::new(900); + s.vault = Some(v); + + // Id-targeting reaches the exact cipher, not the first by name. + let item = s + .get_item(Some("id-second"), "dup", Field::Password) + .unwrap(); + assert_eq!(item.id, "id-second"); + assert_eq!(item.value, "second-secret"); + + // Name-only fallback returns the first match (documents the footgun the + // id path exists to avoid). + let item = s.get_item(None, "dup", Field::Password).unwrap(); + assert_eq!(item.id, "id-first"); + assert_eq!(item.value, "first-secret"); + } + + #[cfg(feature = "clipboard")] + #[test] + fn should_clear_only_when_ours_or_unreadable() { + // Still holds our value → clear. + assert!(should_clear_clipboard(Some("secret"), "secret")); + // Holds something the user copied since → leave it. + assert!(!should_clear_clipboard(Some("other"), "secret")); + // Unreadable → fail safe and clear. + assert!(should_clear_clipboard(None, "secret")); + } + + #[cfg(feature = "clipboard")] + #[test] + fn clipboard_set_errors_when_no_backend() { + // Headless / failed init: copy must decline cleanly, not panic. + let mut s = AgentState::new(900); + s.clipboard = None; + assert!(matches!( + s.clipboard_set("secret"), + Err(IpcError::Internal(_)) + )); + } + #[test] fn resolve_cipher_matches_by_id_then_name() { let enc = [7u8; 32]; @@ -726,6 +860,26 @@ mod tests { } } + fn login_with_password( + id: &str, + plain_name: &str, + password: &str, + enc: &[u8; 32], + mac: &[u8; 32], + ) -> Cipher { + let e = |s: &str| vault_core::EncString::encrypt(enc, mac, s.as_bytes()).serialize(); + Cipher { + id: id.to_owned(), + cipher_type: 1, + name: Some(e(plain_name)), + login: Some(Login { + password: Some(e(password)), + ..Login::default() + }), + ..Cipher::default() + } + } + fn stub_vault() -> Vault { let urls = vault_api::BaseUrls::self_hosted("https://vault.example.org").unwrap(); let client = vault_api::BitwardenClient::new(urls, uuid::Uuid::nil(), "vault-agent-test") diff --git a/crates/vault-cli/src/main.rs b/crates/vault-cli/src/main.rs index dfe10bf..e6b021d 100644 --- a/crates/vault-cli/src/main.rs +++ b/crates/vault-cli/src/main.rs @@ -709,6 +709,8 @@ async fn cmd_get( ) -> Result<(), u8> { let mut stream = connect(socket).await?; let req = Request::Get { + // The CLI selects by name only; id-targeting is a TUI affordance. + id: None, name, field: Some(field), }; diff --git a/crates/vault-ipc/src/proto.rs b/crates/vault-ipc/src/proto.rs index 812a786..90e87f8 100644 --- a/crates/vault-ipc/src/proto.rs +++ b/crates/vault-ipc/src/proto.rs @@ -45,14 +45,44 @@ pub enum Request { /// List all items by decrypted name. Requires an unlocked agent. List, - /// Look up a single item by its decrypted name (case-insensitive). + /// Look up a single item and return one decrypted field. + /// + /// When `id` is `Some`, the agent targets that exact cipher id — the + /// reliable path for a client that already knows which item it means (the + /// TUI passes the selected row's id). When `id` is `None`, the agent falls + /// back to a case-insensitive match on `name`, which is ambiguous if two + /// items share a name. `name` is always carried for human-readable errors. Get { - /// Item name (decrypted form, e.g. `github.com`). + /// Exact cipher id to target, if known. + id: Option, + /// Item name (decrypted form, e.g. `github.com`) — fallback selector + /// and error label. name: String, /// Optional field selector — `password` is the default. field: Option, }, + /// Decrypt one field of the targeted item and place it on the agent's own + /// clipboard, scheduling an auto-clear after `clear_after_secs`. + /// + /// Unlike [`Request::Get`], the plaintext value never crosses the socket: + /// the agent reads, copies, and forgets it, so a short-lived client (or one + /// that quits before the timer fires) can't leak or strand the secret. + /// Targeting mirrors `Get` — `id` is exact, `name` is the fallback / label. + /// On success the agent replies [`Response::Ok`]; a missing field, locked + /// agent, or absent clipboard surfaces as [`Response::Error`]. + Copy { + /// Exact cipher id to target, if known. + id: Option, + /// Item name — fallback selector and error label. + name: String, + /// Field to copy — `password` is the default. + field: Option, + /// Seconds before the agent clears the clipboard; `None` uses the + /// agent default, `Some(0)` disables auto-clear. + clear_after_secs: Option, + }, + /// Soft-delete a cipher by id or decrypted name. /// /// `selector` is matched against `Cipher.id` first (exact); if no id diff --git a/crates/vault-ipc/tests/transport.rs b/crates/vault-ipc/tests/transport.rs index 36fafc0..68f3f20 100644 --- a/crates/vault-ipc/tests/transport.rs +++ b/crates/vault-ipc/tests/transport.rs @@ -131,13 +131,15 @@ async fn status_round_trip_preserves_optionals() { async fn get_request_defaults_to_password() { let (mut a, mut b) = duplex(8 * 1024); let req = Request::Get { + id: None, name: "github.com".into(), field: None, }; write_frame(&mut a, &req).await.unwrap(); let got: Request = read_frame(&mut b).await.unwrap(); match got { - Request::Get { name, field } => { + Request::Get { id, name, field } => { + assert_eq!(id, None); assert_eq!(name, "github.com"); assert_eq!(field.unwrap_or_default(), Field::Password); } diff --git a/crates/vault-tui/Cargo.toml b/crates/vault-tui/Cargo.toml index ed45dc5..4f549e5 100644 --- a/crates/vault-tui/Cargo.toml +++ b/crates/vault-tui/Cargo.toml @@ -26,6 +26,7 @@ clap = { workspace = true } tokio = { workspace = true } ratatui = { workspace = true } crossterm = { workspace = true } +zeroize = { workspace = true } vault-core = { path = "../vault-core" } vault-ipc = { path = "../vault-ipc" } vault-theme = { path = "../vault-theme" } diff --git a/crates/vault-tui/src/app.rs b/crates/vault-tui/src/app.rs index 1f3b27d..dc97126 100644 --- a/crates/vault-tui/src/app.rs +++ b/crates/vault-tui/src/app.rs @@ -3,13 +3,18 @@ //! TUI application state and pure navigation / filter logic. //! //! Everything here is synchronous and crossterm-free so it unit-tests without a -//! terminal: `main.rs` translates key events into calls on these methods, and -//! `ui.rs` renders from this state. Slice 1 is read-only — the state holds only -//! the non-secret [`ListEntry`] metadata the agent already returned. +//! terminal: `main.rs` translates key events into calls on these methods (and +//! performs the agent I/O for reveal/copy), and `ui.rs` renders from this state. +//! The state holds the non-secret [`ListEntry`] metadata plus, transiently, a +//! single revealed secret ([`RevealedSecret`], zeroised on drop and re-masked +//! on any navigation). use std::collections::BTreeSet; +use std::fmt; -use vault_ipc::proto::{ListEntry, Status}; +use zeroize::Zeroizing; + +use vault_ipc::proto::{Field, ListEntry, Status}; /// Which pane currently takes navigation keys. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -55,6 +60,48 @@ pub struct FolderItem { pub filter: FolderFilter, } +/// A secret currently shown in the detail pane: which item and field it +/// belongs to, plus the plaintext. The value is zeroised on drop and never +/// surfaced by `Debug`, so an `App` dump can't leak it. +#[derive(Clone)] +pub struct RevealedSecret { + /// Id of the item the secret belongs to; reveal is dropped when the + /// selection moves off this item. + pub entry_id: String, + /// Which field is revealed. + pub field: Field, + /// Plaintext value, held only while visible. + value: Zeroizing, +} + +impl RevealedSecret { + /// Wrap a freshly-fetched plaintext for display. + #[must_use] + pub fn new(entry_id: String, field: Field, value: String) -> Self { + Self { + entry_id, + field, + value: Zeroizing::new(value), + } + } + + /// The plaintext to render. + #[must_use] + pub fn value(&self) -> &str { + &self.value + } +} + +impl fmt::Debug for RevealedSecret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RevealedSecret") + .field("entry_id", &self.entry_id) + .field("field", &self.field) + .field("value", &"") + .finish() + } +} + /// Top-level TUI state. #[derive(Clone, Debug)] pub struct App { @@ -72,6 +119,11 @@ pub struct App { pub item_sel: usize, /// Which pane has focus. pub focus: Focus, + /// Secret currently revealed in the detail pane, if any. + pub revealed: Option, + /// Transient status-bar message (copy feedback / errors). Cleared on the + /// next key press. + pub toast: Option, /// Set when the user asks to quit. pub should_quit: bool, } @@ -89,6 +141,8 @@ impl App { folder_sel: 0, item_sel: 0, focus: Focus::Items, + revealed: None, + toast: None, should_quit: false, } } @@ -111,6 +165,8 @@ impl App { folder_sel: 0, item_sel: 0, focus: Focus::Items, + revealed: None, + toast: None, should_quit: false, } } @@ -145,6 +201,9 @@ impl App { /// Move the selection down by one in the focused pane (saturating). pub fn move_down(&mut self) { + // Any navigation re-masks: a revealed secret must never linger over a + // row the user has moved away from. + self.revealed = None; match self.focus { Focus::Folders => { if self.folder_sel + 1 < self.folders.len() { @@ -162,7 +221,8 @@ impl App { } /// Move the selection up by one in the focused pane (saturating). - pub const fn move_up(&mut self) { + pub fn move_up(&mut self) { + self.revealed = None; match self.focus { Focus::Folders => { if self.folder_sel > 0 { @@ -175,13 +235,49 @@ impl App { } /// Toggle focus between the folder pane and the item list. - pub const fn focus_next(&mut self) { + pub fn focus_next(&mut self) { + self.revealed = None; self.focus = match self.focus { Focus::Folders => Focus::Items, Focus::Items => Focus::Folders, }; } + /// Whether the item list currently has focus — the gate for copy / reveal + /// actions, which target the selected item. + #[must_use] + pub const fn items_focused(&self) -> bool { + matches!(self.focus, Focus::Items) + } + + /// Whether `field` of the item with `entry_id` is currently revealed. + #[must_use] + pub fn is_revealed(&self, entry_id: &str, field: Field) -> bool { + self.revealed + .as_ref() + .is_some_and(|r| r.entry_id == entry_id && r.field == field) + } + + /// Reveal a freshly-fetched secret in the detail pane. + pub fn reveal(&mut self, secret: RevealedSecret) { + self.revealed = Some(secret); + } + + /// Re-mask any revealed secret. + pub fn hide_revealed(&mut self) { + self.revealed = None; + } + + /// Set the transient status-bar message. + pub fn set_toast(&mut self, msg: impl Into) { + self.toast = Some(msg.into()); + } + + /// Clear the transient status-bar message (called before each key press). + pub fn clear_toast(&mut self) { + self.toast = None; + } + /// Request shutdown on the next loop iteration. pub const fn quit(&mut self) { self.should_quit = true; @@ -336,4 +432,78 @@ mod tests { app.move_down(); // change folder -> item_sel reset assert_eq!(app.item_sel, 0); } + + #[test] + fn reveal_is_tracked_per_item_and_field() { + let mut app = App::browsing(status(), vec![entry("a", None)]); + assert!(!app.is_revealed("id-a", Field::Password)); + app.reveal(RevealedSecret::new( + "id-a".to_owned(), + Field::Password, + "hunter2".to_owned(), + )); + assert!(app.is_revealed("id-a", Field::Password)); + // A different item or field is not considered revealed. + assert!(!app.is_revealed("id-b", Field::Password)); + assert!(!app.is_revealed("id-a", Field::Username)); + app.hide_revealed(); + assert!(!app.is_revealed("id-a", Field::Password)); + } + + #[test] + fn navigation_remasks_a_revealed_secret() { + let entries = vec![entry("a", None), entry("b", None)]; + let mut app = App::browsing(status(), entries); + app.reveal(RevealedSecret::new( + "id-a".to_owned(), + Field::Password, + "secret".to_owned(), + )); + assert!(app.revealed.is_some()); + app.move_down(); + assert!(app.revealed.is_none(), "moving selection must re-mask"); + + // Re-reveal, then a focus switch must also re-mask. + app.reveal(RevealedSecret::new( + "id-a".to_owned(), + Field::Password, + "secret".to_owned(), + )); + app.focus_next(); + assert!(app.revealed.is_none(), "switching panes must re-mask"); + } + + #[test] + fn toast_set_and_clear() { + let mut app = App::browsing(status(), vec![entry("a", None)]); + assert!(app.toast.is_none()); + app.set_toast("copied password"); + assert_eq!(app.toast.as_deref(), Some("copied password")); + app.clear_toast(); + assert!(app.toast.is_none()); + } + + #[test] + fn items_focused_gates_on_focus() { + let mut app = App::browsing(status(), vec![entry("a", None)]); + assert!(app.items_focused()); // browsing starts on the item list + app.focus_next(); + assert!(!app.items_focused()); + } + + #[test] + fn revealed_secret_debug_redacts_plaintext() { + let secret = RevealedSecret::new( + "id-a".to_owned(), + Field::Password, + "super-secret-value".to_owned(), + ); + let rendered = format!("{secret:?}"); + assert!(rendered.contains("RevealedSecret")); + assert!(rendered.contains("")); + assert!( + !rendered.contains("super-secret-value"), + "Debug leaked the plaintext: {rendered}" + ); + } } diff --git a/crates/vault-tui/src/client.rs b/crates/vault-tui/src/client.rs index c7dee4e..9241c0c 100644 --- a/crates/vault-tui/src/client.rs +++ b/crates/vault-tui/src/client.rs @@ -3,8 +3,9 @@ //! Minimal UDS client — one request per fresh connection. //! //! Mirrors `vault-cli`'s `connect` / `exchange`: the TUI is just another agent -//! client and never holds the user key. Slice 1 drives only [`Request::Status`] -//! and [`Request::List`]; later slices add `Get` (copy) over the same path. +//! client and never holds the user key. Drives [`Request::Status`], +//! [`Request::List`], [`Request::Get`] (reveal), and [`Request::Copy`] over the +//! same one-shot path. use std::path::Path; diff --git a/crates/vault-tui/src/main.rs b/crates/vault-tui/src/main.rs index b4c0515..86259c0 100644 --- a/crates/vault-tui/src/main.rs +++ b/crates/vault-tui/src/main.rs @@ -2,11 +2,12 @@ //! Vault TUI — `vault-tui` binary entry point. //! -//! M5 slice 1: a read-only, cruxpass-style three-pane browser over the agent. -//! It is just another UDS client (the user key never crosses into it) and drives -//! only `Request::Status` + `Request::List`. Search / copy / generate land in -//! later slices. Requires a pre-unlocked agent; a locked or absent agent shows a -//! centered banner. +//! A cruxpass-style three-pane browser over the agent. It is just another UDS +//! client (the user key never crosses into it) and drives `Request::Status` + +//! `Request::List` for browsing, `Request::Get` for reveal-on-demand, and +//! `Request::Copy` for clipboard copies (the secret stays in the agent on that +//! path). Search / generate / editing land in later slices. Requires a +//! pre-unlocked agent; a locked or absent agent shows a centered banner. #![forbid(unsafe_code)] @@ -28,13 +29,18 @@ use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use tokio::sync::mpsc; -use vault_ipc::proto::{Request, Response}; +use vault_ipc::proto::{Field, Request, Response}; use vault_ipc::{default_socket_path, sanitize_socket_path}; -use app::App; +use app::{App, RevealedSecret}; const PKG_VERSION: &str = env!("CARGO_PKG_VERSION"); +/// Seconds the agent keeps a copied secret on the clipboard before wiping it. +/// Mirrors the agent's own default and is surfaced in the copy toast so the +/// user knows the window. +const COPY_CLEAR_SECS: u64 = 30; + /// Standard §13.2 attribution block — surfaced via `--version` and `--help`. const ATTRIBUTION: &str = "\ Maintained by Mohamed Hammad @@ -172,6 +178,8 @@ async fn handle_key(state: &mut App, key: KeyEvent, socket: &Path) { state.quit(); return; } + // Each key press supersedes the previous transient message. + state.clear_toast(); match key.code { KeyCode::Char('q') | KeyCode::Esc => state.quit(), KeyCode::Char('j') | KeyCode::Down => state.move_down(), @@ -180,10 +188,74 @@ async fn handle_key(state: &mut App, key: KeyEvent, socket: &Path) { state.focus_next(); } KeyCode::Char('r') => *state = load_app(socket).await, + KeyCode::Char(' ') => toggle_reveal(state, socket).await, + KeyCode::Char('c') => copy_field(state, socket, Field::Password, "password").await, + KeyCode::Char('u') => copy_field(state, socket, Field::Username, "username").await, + KeyCode::Char('o') => copy_field(state, socket, Field::Uri, "URI").await, _ => {} } } +/// Toggle reveal of the selected item's password in the detail pane. The first +/// press fetches the plaintext from the agent (id-targeted, so duplicate names +/// can't mislead it); the second re-masks. No-op unless the item list is +/// focused and a row is selected. +async fn toggle_reveal(state: &mut App, socket: &Path) { + if !state.items_focused() { + return; + } + let Some(sel) = state.selected_entry() else { + return; + }; + if state.is_revealed(&sel.id, Field::Password) { + state.hide_revealed(); + return; + } + let req = Request::Get { + id: Some(sel.id.clone()), + name: sel.name.clone(), + field: Some(Field::Password), + }; + match client::request(socket, &req).await { + Ok(Response::Item(item)) => { + state.reveal(RevealedSecret::new( + sel.id, + Field::Password, + item.value.clone(), + )); + } + Ok(Response::Error(e)) => state.set_toast(format!("reveal failed: {e}")), + Ok(other) => state.set_toast(format!("unexpected response: {other:?}")), + Err(e) => state.set_toast(e.to_string()), + } +} + +/// Ask the agent to copy `field` of the selected item to the clipboard, with a +/// timed auto-clear. The secret stays in the agent and never enters this +/// process. No-op unless the item list is focused and a row is selected. +async fn copy_field(state: &mut App, socket: &Path, field: Field, label: &str) { + if !state.items_focused() { + return; + } + let Some(sel) = state.selected_entry() else { + return; + }; + let req = Request::Copy { + id: Some(sel.id), + name: sel.name, + field: Some(field), + clear_after_secs: Some(COPY_CLEAR_SECS), + }; + match client::request(socket, &req).await { + Ok(Response::Ok) => { + state.set_toast(format!("copied {label} · clears in {COPY_CLEAR_SECS}s")); + } + Ok(Response::Error(e)) => state.set_toast(format!("copy failed: {e}")), + Ok(other) => state.set_toast(format!("unexpected response: {other:?}")), + Err(e) => state.set_toast(e.to_string()), + } +} + /// Restore the terminal to its cooked state. Best-effort: errors are ignored /// because this also runs from the panic hook, where there's nothing to return /// an error to. diff --git a/crates/vault-tui/src/ui.rs b/crates/vault-tui/src/ui.rs index ac29a98..52eefc2 100644 --- a/crates/vault-tui/src/ui.rs +++ b/crates/vault-tui/src/ui.rs @@ -9,10 +9,14 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}; +use vault_ipc::proto::Field; use vault_theme::steelbore; use crate::app::{App, Focus, Screen}; +/// Mask shown for a secret field that has not been revealed. +const MASK: &str = "••••••••"; + /// Parse a `#RRGGBB` palette constant into a ratatui [`Color`]; falls back to /// the terminal default on anything malformed. #[must_use] @@ -140,20 +144,30 @@ fn render_detail(frame: &mut Frame, app: &App, area: Rect) { |e| { let folder = e.folder.clone().unwrap_or_else(|| "(unfiled)".to_owned()); let username = e.username.clone().unwrap_or_else(|| "—".to_owned()); - vec![ + let mut lines = vec![ field_line("Name", &e.name, amber), field_line("Type", type_label(e.cipher_type), info), field_line("User", &username, info), field_line("Folder", &folder, info), field_line("Id", &e.id, info), - Line::from(""), - Line::from(Span::styled( - "reveal/copy land in the next slice", - Style::default() - .fg(hex(steelbore::STEEL_BLUE)) - .add_modifier(Modifier::ITALIC), - )), - ] + ]; + // Logins carry a password; show it masked, revealed on demand. + if e.cipher_type == 1 { + if app.is_revealed(&e.id, Field::Password) { + let value = app.revealed.as_ref().map_or(MASK, |r| r.value()); + lines.push(field_line("Pass", value, amber)); + } else { + lines.push(field_line("Pass", MASK, hex(steelbore::STEEL_BLUE))); + } + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Space reveal · c/u/o copy", + Style::default() + .fg(hex(steelbore::STEEL_BLUE)) + .add_modifier(Modifier::ITALIC), + ))); + lines }, ); let para = Paragraph::new(lines) @@ -200,10 +214,21 @@ fn render_status_bar(frame: &mut Frame, app: &App, area: Rect) { spans.push(Span::raw(" ")); } } - spans.push(Span::styled( - "q quit j/k move Tab pane r refresh · / c u o g : soon", - Style::default().fg(hex(steelbore::STEEL_BLUE)), - )); + // A transient toast (copy feedback / errors) takes the trailing slot; + // otherwise show the key hints. + if let Some(toast) = app.toast.as_deref() { + spans.push(Span::styled( + toast.to_owned(), + Style::default() + .fg(hex(steelbore::MOLTEN_AMBER)) + .add_modifier(Modifier::BOLD), + )); + } else { + spans.push(Span::styled( + "q quit j/k move Tab pane Space reveal c/u/o copy r refresh · / g : soon", + Style::default().fg(hex(steelbore::STEEL_BLUE)), + )); + } frame.render_widget( Paragraph::new(Line::from(spans)).style(Style::default().bg(hex(steelbore::VOID_NAVY))), area, @@ -266,8 +291,19 @@ mod tests { use ratatui::buffer::Buffer; use super::*; + use crate::app::RevealedSecret; use vault_ipc::proto::{ListEntry, Status}; + fn login_entry() -> ListEntry { + ListEntry { + id: "c1".into(), + name: "github.com".into(), + cipher_type: 1, + username: Some("octocat".into()), + folder: Some("Work".into()), + } + } + fn buffer_text(buf: &Buffer) -> String { let area = buf.area; let mut s = String::new(); @@ -331,4 +367,35 @@ mod tests { assert!(text.contains("Locked"), "banner title missing:\n{text}"); assert!(text.contains("no agent") || text.contains("locked")); } + + #[test] + fn detail_masks_login_password_by_default() { + let app = App::browsing(status(), vec![login_entry()]); + let text = draw(&app); + assert!(text.contains(MASK), "password not masked:\n{text}"); + assert!(!text.contains("hunter2")); + } + + #[test] + fn detail_reveals_password_when_set() { + let mut app = App::browsing(status(), vec![login_entry()]); + app.reveal(RevealedSecret::new( + "c1".to_owned(), + Field::Password, + "hunter2".to_owned(), + )); + let text = draw(&app); + assert!(text.contains("hunter2"), "revealed value missing:\n{text}"); + } + + #[test] + fn toast_renders_in_status_bar() { + let mut app = App::browsing(status(), vec![login_entry()]); + app.set_toast("copied password · clears in 30s"); + let text = draw(&app); + assert!( + text.contains("copied password"), + "toast missing from status bar:\n{text}" + ); + } } diff --git a/deny.toml b/deny.toml index 5da5d53..ea16118 100644 --- a/deny.toml +++ b/deny.toml @@ -38,6 +38,12 @@ allow = [ "CC0-1.0", "MPL-2.0", "0BSD", + # Boost Software License 1.0 — permissive and FSF-confirmed GPL-compatible + # (https://www.gnu.org/licenses/license-list.html#boost). Reaches the tree + # via error-code → clipboard-win → arboard (the agent's clipboard backend); + # clipboard-win is Windows-only, surfaced here because deny checks all + # targets. + "BSL-1.0", # webpki-roots ships Mozilla's CA certificate bundle under CDLA-Permissive-2.0 # (a permissive *data* licence, GPL-compatible — it imposes no copyleft on the # linking program). Required since webpki-roots 1.0 relicensed to it.