Skip to content

Commit 4922e3e

Browse files
Merge pull request #15 from Spacecraft-Software/session-resume
feat(vault-agent): session resume across restart via kernel keyring (opt-in)
2 parents 1f15447 + 536c1fb commit 4922e3e

11 files changed

Lines changed: 437 additions & 9 deletions

File tree

CHANGELOG.md

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

1111
### Added
1212

13+
- **Session resume across agent restart (opt-in, Linux).** With the new
14+
`agent.session_keyring` config key, an unlock mirrors the user key into the
15+
Linux **kernel session keyring** (kernel memory — never on disk, never
16+
swapped, possessor-gated, evicted on logout). A restarted agent (crash,
17+
`SIGTERM`, `stop-agent` + auto-spawn) reads it back and resumes **unlocked
18+
without the master password**, bounded by the idle-lock TTL: the keyring entry
19+
carries a kernel timeout (refreshed on activity, throttled), so a dead agent's
20+
session self-expires.
21+
- Lock semantics split: explicit `vault lock` and idle-lock **clear** the
22+
keyring (durably locked); `stop-agent`/`SIGTERM`/crash leave it so the next
23+
agent resumes. `AgentState::lock` (in-memory wipe) vs the new
24+
`lock_and_clear_session`.
25+
- New `crates/vault-agent/src/session.rs` over the pure-Rust `linux-keyutils`
26+
crate (`[target.'cfg(target_os = "linux")']`, MIT/Apache-2.0); a no-op stub
27+
elsewhere. `main.rs` attempts resume at startup via the existing
28+
`unlock::load_cache` + `vault_from_user_key` path.
29+
- `vault config set agent.session_keyring true` (flows to the auto-spawned
30+
agent as `--session-keyring`, mirroring `idle_lock_secs`).
31+
- This is the sole, **default-off** carve-out to PRD §7.3 / G4 ("master key
32+
never resident outside the agent process"); documented in PRD §7.3. Off, the
33+
key never leaves the process. Linux-only — a no-op everywhere else.
34+
- Tests: `SessionBlob` serde round-trip (cross-platform) + a keyring
35+
store/load/clear round-trip that skips gracefully where no keyring exists;
36+
`vault-config` `agent.session_keyring` get/set/unset + `agent_args` flag
37+
emission.
38+
1339
- **Bitwarden personal API-key authentication (2FA accounts).** A new auth path
1440
that uses the OAuth2 `client_credentials` grant, which is *not* 2FA-challenged
1541
— so an account with two-factor auth enabled can finally authenticate without

Cargo.lock

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"
7373
url = "2"
7474
uuid = { version = "1", features = ["v4", "serde"] }
7575
dirs = "5"
76+
# Linux kernel keyring (pure Rust, no C lib — avoids the ring/-flto link
77+
# breakage). MIT/Apache-2.0; GPL-3.0-or-later compatible. Linux-only, gated
78+
# behind `cfg(target_os = "linux")` in vault-agent.
79+
linux-keyutils = "0.2"
7680
time = { version = "0.3", features = ["serde", "serde-well-known"] }
7781
fs2 = "0.4"
7882

PRD.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ keeps the crypto, sync, and storage code consolidated and auditable.
5252
| G1 | Fully functional Bitwarden client offline once initially synced. |
5353
| G2 | TUI usable as a daily driver; sub-100 ms perceived latency on local operations. |
5454
| G3 | CLI achieves practical parity with `rbw`'s read+write surface and accepts/emits JSON everywhere. |
55-
| G4 | Master key never resident outside the agent process; locked on idle, signal, or explicit `lock`. |
55+
| G4 | Master key never resident outside the agent process; locked on idle, signal, or explicit `lock`. (Opt-in carve-out: §7.3 `session_keyring`.) |
5656
| G5 | Works against both `bitwarden.com` and self-hosted Vaultwarden, including cert pinning. |
5757
| G6 | Headless install (`--no-default-features --features cli`) ships a single binary, no TUI deps. |
5858
| G7 | Conformance with the Spacecraft Software Standard (§3 priority hierarchy, §4 license, §5 posture, §6.3 signed commits, §12 timestamps, §13 attribution). |
@@ -146,6 +146,15 @@ envelope; exit codes documented in `docs/exit_codes.md`.
146146
- Holds: unwrapped master key, derived item-encryption keys, current sync revision.
147147
- Locks on: idle timeout (default 15 min, configurable), `vault lock`, `SIGTERM`, screen-lock dbus signal where available.
148148
- Auto-spawned by any client on first use; never run as root; socket mode `0600`.
149+
- **Session resume (opt-in, Linux).** With `agent.session_keyring` enabled, the
150+
unwrapped user key may *also* reside in the Linux kernel **session keyring**
151+
kernel memory, never on disk, never swapped, possessor-gated, evicted on
152+
logout — so a restarted agent (crash / `SIGTERM` / `stop-agent` + auto-spawn)
153+
resumes unlocked without the master password, bounded by the idle-lock TTL
154+
(the keyring entry carries a kernel timeout). Explicit `vault lock` and
155+
idle-lock clear it; `SIGTERM`/`stop-agent` leave it for resume. This is the
156+
sole, default-off carve-out to G4's "master key never resident outside the
157+
agent process"; off, the key never leaves the process.
149158

150159
### 7.4 Storage
151160

README.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,26 @@ The key is protected at rest by filesystem permissions (`0600`) only: it must
111111
be usable *before* the vault is unlocked, so it can't be encrypted under your
112112
key — the same trust level as the stored refresh token or an SSH private key.
113113

114+
### Stay unlocked across restarts (Linux, opt-in)
115+
116+
By default the agent holds the key only in its own memory, so any restart
117+
(crash, `stop-agent`, logout) means a fresh unlock. On Linux you can opt into
118+
resuming a session across an agent restart:
119+
120+
```sh
121+
vault config set agent.session_keyring true
122+
```
123+
124+
With this on, an unlock also mirrors the key into the Linux **kernel session
125+
keyring** (kernel memory, never on disk, never swapped, evicted on logout). A
126+
restarted agent reads it back and comes up unlocked — but only within the
127+
idle-lock window (`agent.idle_lock_secs`): the keyring entry self-expires, so a
128+
dead agent's session doesn't linger. An explicit `vault lock` (or idle-lock)
129+
forgets it and forces a full unlock; a plain `stop-agent`/`SIGTERM` leaves it so
130+
the next auto-spawn resumes. This is an opt-in relaxation of Vault's "key never
131+
leaves the agent" posture (PRD §7.3); it's off by default and a no-op on
132+
non-Linux.
133+
114134
## Configuration
115135

116136
Persistent settings live at `$XDG_CONFIG_HOME/vault/config.toml`, managed with
@@ -123,8 +143,9 @@ vault config set agent.idle_lock_secs 600
123143
vault config unset clipboard.clear_secs
124144
```
125145

126-
Recognised keys: `clipboard.clear_secs` (auto-clear window, `0` disables) and
127-
`agent.idle_lock_secs` (idle-lock timeout, `0` disables). When the CLI
146+
Recognised keys: `clipboard.clear_secs` (auto-clear window, `0` disables),
147+
`agent.idle_lock_secs` (idle-lock timeout, `0` disables), and
148+
`agent.session_keyring` (resume across restarts; see above). When the CLI
128149
auto-starts the agent, these populate its launch flags. Wipe the on-disk item
129150
cache (and drop a running agent's keys) with `vault purge`.
130151

crates/vault-agent/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ clipboard = ["dep:arboard"]
3232
anyhow = { workspace = true }
3333
tokio = { workspace = true }
3434
clap = { workspace = true }
35+
serde = { workspace = true }
3536
serde_json = { workspace = true }
3637
zeroize = { workspace = true }
3738
uuid = { workspace = true }
@@ -43,5 +44,10 @@ vault-ipc = { path = "../vault-ipc" }
4344
vault-store = { path = "../vault-store" }
4445
vault-api = { path = "../vault-api" }
4546

47+
# Linux kernel session keyring — backs the opt-in `agent.session_keyring`
48+
# session-resume feature. Pure Rust (no libkeyutils C dep). Linux-only.
49+
[target.'cfg(target_os = "linux")'.dependencies]
50+
linux-keyutils = { workspace = true }
51+
4652
[dev-dependencies]
4753
tempfile = { workspace = true }

crates/vault-agent/src/main.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ use vault_ipc::default_socket_path;
2222
#[cfg(feature = "clipboard")]
2323
mod clipboard;
2424
mod server;
25+
mod session;
2526
mod state;
2627
mod unlock;
2728

@@ -65,6 +66,11 @@ struct Args {
6566
/// build without clipboard support.
6667
#[arg(long)]
6768
clipboard_clear_secs: Option<u64>,
69+
/// Mirror the user key into the Linux kernel session keyring on unlock so a
70+
/// restarted agent resumes without the master password, within the
71+
/// idle-lock TTL (opt-in; PRD §7.3 carve-out). No effect on non-Linux.
72+
#[arg(long)]
73+
session_keyring: bool,
6874
}
6975

7076
#[tokio::main(flavor = "current_thread")]
@@ -82,6 +88,11 @@ async fn main() -> anyhow::Result<()> {
8288
};
8389
#[cfg(not(feature = "clipboard"))]
8490
let agent = AgentState::new(args.idle_lock_secs);
91+
let mut agent = agent;
92+
agent.session_keyring = args.session_keyring;
93+
// Opt-in: resume an unlocked session left in the kernel keyring by a prior
94+
// agent (e.g. after a crash / restart), within its idle-lock deadline.
95+
try_resume(&mut agent);
8596
let state = Arc::new(Mutex::new(agent));
8697

8798
if args.idle_lock_secs > 0 {
@@ -117,6 +128,40 @@ fn resolve_clear_secs(flag: Option<u64>, env: Option<&str>) -> u64 {
117128
.unwrap_or(30)
118129
}
119130

131+
/// Opt-in session resume: if a prior agent left an unlocked session in the
132+
/// kernel keyring and it hasn't passed its idle-lock deadline, rebuild the
133+
/// vault from it + the on-disk cache so the agent comes up unlocked. Any
134+
/// failure (disabled, no entry, expired, missing cache) leaves it locked.
135+
fn try_resume(agent: &mut AgentState) {
136+
if !agent.session_keyring {
137+
return;
138+
}
139+
let Some(blob) = session::load() else {
140+
return;
141+
};
142+
let now = std::time::SystemTime::now()
143+
.duration_since(std::time::UNIX_EPOCH)
144+
.map_or(0, |d| d.as_secs());
145+
// deadline_unix == 0 means "no expiry" (idle-lock disabled).
146+
if blob.deadline_unix != 0 && now >= blob.deadline_unix {
147+
session::clear();
148+
return;
149+
}
150+
let Some(cache) = unlock::load_cache(&blob.server, &blob.email) else {
151+
return;
152+
};
153+
let user_enc = zeroize::Zeroizing::new(blob.user_enc);
154+
let user_mac = zeroize::Zeroizing::new(blob.user_mac);
155+
match unlock::vault_from_user_key(&cache, &blob.server, &blob.email, user_enc, user_mac) {
156+
Ok(vault) => {
157+
agent.vault = Some(vault);
158+
agent.touch();
159+
eprintln!("vault-agent: resumed session from kernel keyring");
160+
}
161+
Err(e) => eprintln!("vault-agent: keyring resume failed: {e}"),
162+
}
163+
}
164+
120165
fn pick_socket(cli: Option<PathBuf>) -> anyhow::Result<PathBuf> {
121166
if let Some(p) = cli {
122167
return Ok(p);

crates/vault-agent/src/server.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
9898
Ok(vault) => {
9999
let mut s = state.lock().await;
100100
s.vault = Some(vault);
101+
s.persist_session();
101102
s.touch();
102103
Response::Ok
103104
}
@@ -112,6 +113,7 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
112113
Ok(vault) => {
113114
let mut s = state.lock().await;
114115
s.vault = Some(vault);
116+
s.persist_session();
115117
s.touch();
116118
Response::Ok
117119
}
@@ -149,7 +151,8 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
149151
}
150152
Request::Lock => {
151153
let mut s = state.lock().await;
152-
s.lock();
154+
// Explicit lock: also forget any persisted keyring session.
155+
s.lock_and_clear_session();
153156
s.touch();
154157
Response::Ok
155158
}
@@ -367,7 +370,8 @@ pub async fn idle_lock_loop(state: Arc<Mutex<AgentState>>) {
367370
sleep(Duration::from_secs(15)).await;
368371
let mut s = state.lock().await;
369372
if s.idle_lock_due() {
370-
s.lock();
373+
// Idle-lock is a security event: forget the keyring session too.
374+
s.lock_and_clear_session();
371375
eprintln!("vault-agent: idle-lock triggered");
372376
}
373377
if s.shutdown_requested {

0 commit comments

Comments
 (0)