Skip to content

Commit d0ad250

Browse files
Merge pull request #5 from Spacecraft-Software/clipboard-hardening
feat(vault): clipboard hardening — clear-on-lock, backend trait, OSC52, configurable interval
2 parents 4723c7e + 5de9568 commit d0ad250

14 files changed

Lines changed: 609 additions & 68 deletions

File tree

CHANGELOG.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,44 @@ range may break in any release.
1010

1111
### Added
1212

13+
- **Clipboard hardening — clear-on-lock sweep, backend trait, OSC52 fallback,
14+
configurable interval.** Closes the secret-can-outlive-the-agent limitation
15+
tracked since the M5 copy slice, and delivers PRD §7.5 in its
16+
architecturally honest shape.
17+
- **Clear-on-lock sweep.** The agent remembers the last value it copied
18+
(`Zeroizing`, dropped once a timer clears it) and `lock()` now sweeps it
19+
off the clipboard — covering `vault lock`, `Quit`/`stop-agent`,
20+
idle-lock, and the new **SIGTERM handler** (PRD §7.3: the agent locks,
21+
sweeps, removes its socket, and exits on SIGTERM). The sweep keeps the
22+
only-if-still-ours rule: a newer copy by the user is never clobbered.
23+
- **Backend trait (PRD §7.5).** New `vault-agent/src/clipboard.rs` defines
24+
a `Backend` trait (`arboard` is the one system implementation; detection
25+
unchanged) so tests inject a fake clipboard — the sweep logic is now
26+
exercised end-to-end on headless CI — and a config-selected backend can
27+
slot in later. `Status` gains a serde-defaulted `clipboard_backend`
28+
field (`"arboard"` / absent) so clients can see what they're talking to;
29+
old-agent frames still decode (regression-tested).
30+
- **OSC52 fallback (client-side, by design).** OSC52 copies by escaping to
31+
the user's *terminal* — something the detached agent fundamentally cannot
32+
do, so PRD §7.5's SSH/tmux fallback lives in the TUI: when the agent
33+
declines with the new typed `Error::ClipboardUnavailable`, the TUI
34+
fetches the value (id-targeted `Get` — the one path where the secret
35+
crosses the local UDS, same as `vault get`) and emits the OSC52 sequence
36+
itself, with tmux DCS-passthrough wrapping when `$TMUX` is set
37+
(`set-clipboard on` required). The TUI runs its own 30 s timed clear
38+
(OSC52 `!` payload) while it lives, and sweeps on quit; a generated
39+
password falls back without any `Get` since it's already local.
40+
- **Configurable interval.** `vault-agent --clipboard-clear-secs N` (then
41+
`$VAULT_CLIPBOARD_CLEAR_SECS`, then 30; `0` disables) sets the agent's
42+
default; `Request::Copy`/`CopyText` still override per call. The new
43+
`Response::Copied { clear_after_secs }` reports the *effective* window,
44+
so the TUI's toast now shows the agent's real interval instead of a
45+
hardcoded 30. The future `vault config` file maps onto the same knob.
46+
- Tests: fake-backend units for sweep-on-lock, never-clobber-newer-copy,
47+
and timer/sweep marker interplay; `Status` backend-name reporting;
48+
interval-resolution precedence; OSC52 sequence/clear/tmux-wrapping
49+
units; and the old-agent `Status` decode regression.
50+
1351
- **M5 (slice 5) — CLI agent auto-spawn + headless feature gate.** The two
1452
non-TUI M5 items, closing out the milestone.
1553
- **Auto-spawn (PRD §7.3).** Any `vault` verb now starts `vault-agent`

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
3+
//! Clipboard backends (PRD §7.5) — the agent-side abstraction.
4+
//!
5+
//! The agent owns one [`Backend`] for its lifetime (on X11 the owning process
6+
//! serves the selection, so the handle can't be transient). `arboard` covers
7+
//! Wayland, X11, and macOS; when it can't initialise (headless box, no
8+
//! display) the agent runs with no backend and `Copy`/`CopyText` decline with
9+
//! a typed `ClipboardUnavailable` — clients fall back from there (the TUI
10+
//! emits OSC52 itself; a daemon has no terminal to escape to, so OSC52 can
11+
//! never live here). The trait exists so tests can inject a fake and so a
12+
//! config-selected backend can slot in later without touching `AgentState`.
13+
14+
use vault_ipc::proto::Error as IpcError;
15+
16+
/// One system clipboard the agent can write to.
17+
///
18+
/// Object-safe and `Send` so the boxed backend can live inside the
19+
/// tokio-shared `AgentState`.
20+
pub trait Backend: Send {
21+
/// Short identifier surfaced in `Status.clipboard_backend`.
22+
fn name(&self) -> &'static str;
23+
24+
/// Place `value` on the clipboard.
25+
///
26+
/// # Errors
27+
///
28+
/// Returns an [`IpcError`] when the underlying clipboard write fails.
29+
fn set_text(&mut self, value: &str) -> Result<(), IpcError>;
30+
31+
/// Current clipboard contents, `None` if unreadable.
32+
fn get_text(&mut self) -> Option<String>;
33+
34+
/// Best-effort clear; errors are not actionable mid-shutdown.
35+
fn clear(&mut self);
36+
}
37+
38+
/// The `arboard`-backed system clipboard (Wayland / X11 / macOS).
39+
pub struct Arboard(arboard::Clipboard);
40+
41+
impl Backend for Arboard {
42+
fn name(&self) -> &'static str {
43+
"arboard"
44+
}
45+
46+
fn set_text(&mut self, value: &str) -> Result<(), IpcError> {
47+
self.0
48+
.set_text(value.to_owned())
49+
.map_err(|e| IpcError::Internal(format!("clipboard write failed: {e}")))
50+
}
51+
52+
fn get_text(&mut self) -> Option<String> {
53+
self.0.get_text().ok()
54+
}
55+
56+
fn clear(&mut self) {
57+
let _ = self.0.clear();
58+
}
59+
}
60+
61+
/// Detect a usable backend, degrading to `None` (with a warning) when no
62+
/// display/compositor is reachable — copy requests then decline cleanly.
63+
pub fn detect() -> Option<Box<dyn Backend>> {
64+
match arboard::Clipboard::new() {
65+
Ok(cb) => Some(Box::new(Arboard(cb))),
66+
Err(e) => {
67+
eprintln!("vault-agent: clipboard unavailable, copy will be declined: {e}");
68+
None
69+
}
70+
}
71+
}

crates/vault-agent/src/main.rs

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ use tokio::sync::Mutex;
1919

2020
use vault_ipc::default_socket_path;
2121

22+
#[cfg(feature = "clipboard")]
23+
mod clipboard;
2224
mod server;
2325
mod state;
2426
mod unlock;
@@ -57,23 +59,64 @@ struct Args {
5759
/// many seconds with no client activity. `0` disables auto-lock.
5860
#[arg(long, default_value_t = 900)]
5961
idle_lock_secs: u64,
62+
/// Default seconds before a copied secret is wiped from the clipboard
63+
/// when the client doesn't specify; `0` disables the default auto-clear.
64+
/// Falls back to `$VAULT_CLIPBOARD_CLEAR_SECS`, then 30. No effect on a
65+
/// build without clipboard support.
66+
#[arg(long)]
67+
clipboard_clear_secs: Option<u64>,
6068
}
6169

6270
#[tokio::main(flavor = "current_thread")]
6371
async fn main() -> anyhow::Result<()> {
6472
let args = Args::parse();
6573
let path = pick_socket(args.socket)?;
66-
let state = Arc::new(Mutex::new(AgentState::new(args.idle_lock_secs)));
74+
#[cfg(feature = "clipboard")]
75+
let agent = {
76+
let mut a = AgentState::new(args.idle_lock_secs);
77+
a.clipboard_clear_secs = resolve_clear_secs(
78+
args.clipboard_clear_secs,
79+
std::env::var("VAULT_CLIPBOARD_CLEAR_SECS").ok().as_deref(),
80+
);
81+
a
82+
};
83+
#[cfg(not(feature = "clipboard"))]
84+
let agent = AgentState::new(args.idle_lock_secs);
85+
let state = Arc::new(Mutex::new(agent));
6786

6887
if args.idle_lock_secs > 0 {
6988
let st = state.clone();
7089
tokio::spawn(server::idle_lock_loop(st));
7190
}
7291

92+
// PRD §7.3: lock on SIGTERM. Locking also sweeps a still-pending
93+
// clipboard copy; the socket file is removed so the next CLI call
94+
// auto-spawns cleanly instead of hitting a stale socket.
95+
{
96+
let st = state.clone();
97+
let sock = path.clone();
98+
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
99+
tokio::spawn(async move {
100+
term.recv().await;
101+
st.lock().await.lock();
102+
let _ = std::fs::remove_file(&sock);
103+
eprintln!("vault-agent: SIGTERM — keys dropped, exiting");
104+
std::process::exit(0);
105+
});
106+
}
107+
73108
server::run(path, state).await?;
74109
Ok(())
75110
}
76111

112+
/// Effective default auto-clear interval: `--clipboard-clear-secs` wins, then
113+
/// `$VAULT_CLIPBOARD_CLEAR_SECS` (ignored when unparsable), then 30 s.
114+
#[cfg(feature = "clipboard")]
115+
fn resolve_clear_secs(flag: Option<u64>, env: Option<&str>) -> u64 {
116+
flag.or_else(|| env.and_then(|v| v.parse().ok()))
117+
.unwrap_or(30)
118+
}
119+
77120
fn pick_socket(cli: Option<PathBuf>) -> anyhow::Result<PathBuf> {
78121
if let Some(p) = cli {
79122
return Ok(p);
@@ -86,3 +129,21 @@ fn pick_socket(cli: Option<PathBuf>) -> anyhow::Result<PathBuf> {
86129
}
87130
default_socket_path().ok_or_else(|| anyhow::anyhow!("no XDG_RUNTIME_DIR / TMPDIR available"))
88131
}
132+
133+
#[cfg(test)]
134+
mod tests {
135+
#[cfg(feature = "clipboard")]
136+
#[test]
137+
fn resolve_clear_secs_precedence() {
138+
use super::resolve_clear_secs;
139+
assert_eq!(resolve_clear_secs(Some(45), Some("60")), 45, "flag wins");
140+
assert_eq!(resolve_clear_secs(None, Some("60")), 60, "env next");
141+
assert_eq!(
142+
resolve_clear_secs(None, Some("nope")),
143+
30,
144+
"bad env ignored"
145+
);
146+
assert_eq!(resolve_clear_secs(None, None), 30, "default");
147+
assert_eq!(resolve_clear_secs(Some(0), Some("60")), 0, "0 disables");
148+
}
149+
}

crates/vault-agent/src/server.rs

Lines changed: 18 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -155,20 +155,21 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
155155
}
156156
Err(e) => Err(e),
157157
};
158+
let secs = clear_after_secs.unwrap_or(s.clipboard_clear_secs);
158159
s.touch();
159160
drop(s);
160161
match outcome {
161162
Ok(value) => {
162-
schedule_clipboard_clear(state.clone(), value, clear_after_secs);
163-
Response::Ok
163+
schedule_clipboard_clear(state.clone(), value, secs);
164+
Response::Copied(vault_ipc::proto::Copied {
165+
clear_after_secs: secs,
166+
})
164167
}
165168
Err(e) => Response::Error(e),
166169
}
167170
}
168171
#[cfg(not(feature = "clipboard"))]
169-
Request::Copy { .. } => Response::Error(vault_ipc::proto::Error::Internal(
170-
"clipboard support not compiled in".to_owned(),
171-
)),
172+
Request::Copy { .. } => Response::Error(vault_ipc::proto::Error::ClipboardUnavailable),
172173
#[cfg(feature = "clipboard")]
173174
Request::CopyText {
174175
text,
@@ -192,20 +193,21 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
192193
} else {
193194
Err(vault_ipc::proto::Error::Locked)
194195
};
196+
let secs = clear_after_secs.unwrap_or(s.clipboard_clear_secs);
195197
s.touch();
196198
drop(s);
197199
match outcome {
198200
Ok(value) => {
199-
schedule_clipboard_clear(state.clone(), value, clear_after_secs);
200-
Response::Ok
201+
schedule_clipboard_clear(state.clone(), value, secs);
202+
Response::Copied(vault_ipc::proto::Copied {
203+
clear_after_secs: secs,
204+
})
201205
}
202206
Err(e) => Response::Error(e),
203207
}
204208
}
205209
#[cfg(not(feature = "clipboard"))]
206-
Request::CopyText { .. } => Response::Error(vault_ipc::proto::Error::Internal(
207-
"clipboard support not compiled in".to_owned(),
208-
)),
210+
Request::CopyText { .. } => Response::Error(vault_ipc::proto::Error::ClipboardUnavailable),
209211
Request::Remove { selector } => {
210212
// Hold the agent mutex across the network call. Vault is
211213
// single-user / single-agent, so request concurrency is low and
@@ -284,26 +286,18 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
284286
}
285287
}
286288

287-
/// Default seconds before the agent wipes a copied secret from the clipboard.
288-
///
289-
/// 30 s follows common password-manager practice (and Vault PRD §7.2): long
290-
/// enough to paste, short enough to bound exposure. `Request::Copy` overrides
291-
/// per call; config-driven tuning lands in a later slice.
292-
#[cfg(feature = "clipboard")]
293-
const DEFAULT_CLIPBOARD_CLEAR_SECS: u64 = 30;
294-
295-
/// Spawn a one-shot task that wipes the clipboard after `clear_after_secs` (or
296-
/// the default), but only if it still holds the value we copied. `Some(0)`
297-
/// disables the auto-clear. The task carries the secret so the clear survives
298-
/// the requesting client quitting.
289+
/// Spawn a one-shot task that wipes the clipboard after `secs` (the effective
290+
/// interval — the caller already applied the agent default), but only if it
291+
/// still holds the value we copied. `0` disables the auto-clear. The task
292+
/// carries the secret so the clear survives the requesting client quitting;
293+
/// if the *agent* goes down first, `AgentState::lock`'s sweep covers it.
299294
#[cfg(feature = "clipboard")]
300295
fn schedule_clipboard_clear(
301296
state: Arc<Mutex<AgentState>>,
302297
value: zeroize::Zeroizing<String>,
303-
clear_after_secs: Option<u64>,
298+
secs: u64,
304299
) {
305300
use tokio::time::{Duration, sleep};
306-
let secs = clear_after_secs.unwrap_or(DEFAULT_CLIPBOARD_CLEAR_SECS);
307301
if secs == 0 {
308302
return;
309303
}

0 commit comments

Comments
 (0)