secrets-vault 2.4.0

AES-256-GCM encrypted key-value vault with PBKDF2 key derivation. Store API keys and tokens securely instead of plaintext dotfiles.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
//! Encrypted, biometric-gated metadata registry.
//!
//! Stores NO secret values — only the access-control map: which logical projects
//! exist (→ their GCP project id + key names), and which agent is granted which
//! project at what scope. Encrypted at rest (reuses the audited QVLT crypto via
//! `encrypt_blob`) and unlocked by the same biometric Keychain passphrase as the
//! vault. So a background agent can't even learn what other projects exist —
//! reading the map requires your fingerprint.
//!
//! NOTE on agent identity: `resolve_agent` walks the process ancestry, which a
//! same-user adversary can spoof (rename/re-parent). It is therefore a SOFT layer
//! — for accident-prevention, audit, and informed prompts — NOT a hard boundary.
//! The hard boundary is the Touch ID tap.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

use serde::{Deserialize, Serialize};

use secrets_vault::{decrypt_blob, decrypt_raw_blob, encrypt_blob, encrypt_raw_blob};

#[derive(Default, Serialize, Deserialize)]
pub struct Registry {
    /// logical project name → metadata
    #[serde(default)]
    pub projects: BTreeMap<String, ProjectMeta>,
    /// agent id → (project name → grant)
    #[serde(default)]
    pub grants: BTreeMap<String, BTreeMap<String, Grant>>,
}

#[derive(Default, Serialize, Deserialize)]
pub struct ProjectMeta {
    pub gcp_project: String,
    #[serde(default)]
    pub keys: Vec<String>,
}

#[derive(Clone, Serialize, Deserialize)]
pub struct Grant {
    pub scope: Scope,
}

#[derive(Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Scope {
    /// Persistent until explicitly revoked.
    Always,
    /// Valid until this Unix epoch second (a "session" grant).
    Session { expires: u64 },
}

pub fn now() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

impl Registry {
    pub fn path(dir: &Path) -> PathBuf {
        dir.join("registry.enc")
    }

    /// Load + decrypt under the HKDF-derived registry key (v2, spec §6.2).
    /// Returns a fresh empty registry if none exists yet. A legacy v1
    /// (passphrase-PBKDF2) container is a hard error naming the fix — it must
    /// be converted by `secrets migrate`, not silently reinterpreted.
    pub fn load_raw(dir: &Path, key: &[u8; 32]) -> Result<Self, String> {
        match std::fs::read(Self::path(dir)) {
            Ok(data) => {
                if data.starts_with(b"QVLT") {
                    return Err(
                        "registry.enc is in the legacy v1 format — run `secrets migrate`".into()
                    );
                }
                let plain = decrypt_raw_blob(&data, key)
                    .map_err(|e| format!("registry decrypt: {e}"))?;
                serde_json::from_slice(&plain).map_err(|e| format!("registry parse: {e}"))
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Registry::default()),
            Err(e) => Err(format!("reading registry: {e}")),
        }
    }

    /// Legacy v1 save (passphrase-PBKDF2 container) — ONLY while the vault
    /// itself is still v1; `secrets migrate` converts both together.
    pub fn save_v1(&self, dir: &Path, passphrase: &str) -> Result<(), String> {
        let json = serde_json::to_vec_pretty(self).map_err(|e| format!("serialize: {e}"))?;
        let enc = encrypt_blob(&json, passphrase).map_err(|e| format!("encrypt: {e}"))?;
        std::fs::create_dir_all(dir).ok();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).ok();
        }
        let path = Self::path(dir);
        std::fs::write(&path, &enc).map_err(|e| format!("writing registry: {e}"))?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).ok();
        }
        Ok(())
    }

    /// Legacy v1 load (passphrase-PBKDF2 container) — migration + v1-vault
    /// fallback only.
    pub fn load_v1(dir: &Path, passphrase: &str) -> Result<Self, String> {
        match std::fs::read(Self::path(dir)) {
            Ok(data) => {
                let plain =
                    decrypt_blob(&data, passphrase).map_err(|e| format!("registry decrypt: {e}"))?;
                serde_json::from_slice(&plain).map_err(|e| format!("registry parse: {e}"))
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Registry::default()),
            Err(e) => Err(format!("reading registry: {e}")),
        }
    }

    /// Encrypt under the registry key + write with 0700 dir / 0600 file.
    pub fn save_raw(&self, dir: &Path, key: &[u8; 32]) -> Result<(), String> {
        let json = serde_json::to_vec_pretty(self).map_err(|e| format!("serialize: {e}"))?;
        let enc = encrypt_raw_blob(&json, key).map_err(|e| format!("encrypt: {e}"))?;
        std::fs::create_dir_all(dir).ok();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).ok();
        }
        let path = Self::path(dir);
        std::fs::write(&path, &enc).map_err(|e| format!("writing registry: {e}"))?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).ok();
        }
        Ok(())
    }

    /// The valid grant for (agent, project) at `now`, treating expired sessions
    /// as absent.
    pub fn grant_for(&self, agent: &str, project: &str, now: u64) -> Option<&Grant> {
        let g = self.grants.get(agent)?.get(project)?;
        match g.scope {
            Scope::Always => Some(g),
            Scope::Session { expires } if expires > now => Some(g),
            _ => None,
        }
    }

    pub fn set_grant(&mut self, agent: &str, project: &str, scope: Scope) {
        self.grants
            .entry(agent.to_string())
            .or_default()
            .insert(project.to_string(), Grant { scope });
    }

    pub fn revoke(&mut self, agent: &str, project: &str) -> bool {
        if let Some(m) = self.grants.get_mut(agent) {
            let removed = m.remove(project).is_some();
            if m.is_empty() {
                self.grants.remove(agent);
            }
            removed
        } else {
            false
        }
    }
}

// ── Agent resolution (process ancestry; SOFT) ──

/// Agent CLIs we recognize by process name anywhere in the ancestry chain.
pub const KNOWN_AGENTS: &[&str] = &[
    "claude", "grok", "codex", "cursor", "aider", "copilot", "gemini", "cody", "continue",
];

/// Resolve the agent driving us by walking the parent-process chain. Returns the
/// recognized agent id, or None when run by a plain shell / a human.
#[cfg(target_os = "macos")]
pub fn resolve_agent() -> Option<String> {
    resolve_agent_from(std::process::id() as i32)
}

/// Walk the ancestry starting from an arbitrary pid — the session broker uses
/// this on the socket peer's audit-token pid to resolve the CALLER's agent
/// server-side (spec §6.2), never trusting a client claim.
#[cfg(target_os = "macos")]
pub fn resolve_agent_from(start_pid: i32) -> Option<String> {
    let mut pid = start_pid;
    for _ in 0..24 {
        let (ppid, comm) = proc_info(pid)?;
        let lc = comm.to_lowercase();
        if let Some(a) = KNOWN_AGENTS.iter().find(|a| lc.contains(**a)) {
            return Some((*a).to_string());
        }
        // Versioned installs put the agent's name in the executable PATH, not
        // its process name: Claude Code runs as e.g.
        // ~/.local/share/claude/versions/2.1.238, so pbi_comm/pbi_name is
        // "2.1.238" and the comm match above never fires. Check the full
        // executable path's components as well. A false positive here (a
        // human's binary living under a directory named after an agent) only
        // makes enforcement STRICTER — that caller now needs a grant — while
        // the miss it repairs was a silent grant: `exec` read None as "a
        // human is driving" and skipped the registry check entirely.
        if let Some(path) = proc_path(pid) {
            let lp = path.to_lowercase();
            if let Some(a) = KNOWN_AGENTS
                .iter()
                .find(|a| lp.split('/').any(|seg| seg.contains(**a)))
            {
                return Some((*a).to_string());
            }
        }
        if ppid <= 1 {
            break;
        }
        pid = ppid;
    }
    None
}

/// Full executable path via proc_pidpath — the only ancestry signal that
/// survives a versioned-basename install (see resolve_agent_from). None when
/// the pid is gone or the path is unreadable; the caller just falls back to
/// the comm-only verdict for that ancestor.
#[cfg(target_os = "macos")]
fn proc_path(pid: i32) -> Option<String> {
    // PROC_PIDPATHINFO_MAXSIZE (4 * MAXPATHLEN); the libc crate doesn't
    // export the constant.
    let mut buf = vec![0u8; 4 * libc::PATH_MAX as usize];
    let n = unsafe {
        libc::proc_pidpath(pid, buf.as_mut_ptr() as *mut libc::c_void, buf.len() as u32)
    };
    if n <= 0 {
        return None;
    }
    Some(String::from_utf8_lossy(&buf[..n as usize]).into_owned())
}

#[cfg(target_os = "macos")]
fn proc_info(pid: i32) -> Option<(i32, String)> {
    let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
    let size = std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int;
    let n = unsafe {
        libc::proc_pidinfo(
            pid,
            libc::PROC_PIDTBSDINFO,
            0,
            &mut info as *mut _ as *mut libc::c_void,
            size,
        )
    };
    if n <= 0 {
        return None;
    }
    let cstr = |buf: &[libc::c_char]| -> String {
        buf.iter().take_while(|&&c| c != 0).map(|&c| c as u8 as char).collect()
    };
    let name = {
        let full = cstr(&info.pbi_name); // up to 32 chars
        if full.is_empty() {
            cstr(&info.pbi_comm) // 16-char fallback
        } else {
            full
        }
    };
    Some((info.pbi_ppid as i32, name))
}

// ── Windows ancestry (Toolhelp) ──
//
// This existed as `None`-returning stubs, which was NOT a neutral fallback.
// `exec` reads it as "no recognized agent is driving this — a human is", and
// SKIPS the grant check entirely (main.rs: `if let Some(agent) = agent`). So on
// Windows every caller, including a real agent, walked past the registry
// unchecked: a silent grant, the exact failure mode this crate exists to
// prevent. Windows can answer the ancestry question properly, so it now does.

/// Walk the parent chain with a single Toolhelp snapshot.
///
/// LIMITS, same soft heuristic the macOS path documents: process names are
/// same-user spoofable, and an agent that runs under a generic host name
/// (`node.exe`, `python.exe`) resolves to `None` — i.e. it is treated as a
/// human, and the grant check is skipped in favour of the presence gate.
/// Toolhelp also reports only the exe BASENAME, so a versioned install whose
/// binary is named after the version (the macOS layout is
/// `…/claude/versions/2.1.238`) would not resolve here either; macOS repairs
/// that by matching the full proc_pidpath, Windows has no path check yet. That
/// is inherited design, not something this port introduced; on Windows the
/// backstop is the Windows Hello prompt on every master-key read. It is NOT a
/// backstop when `SECRETS_PASSPHRASE` is set, which is why that variable is
/// scrubbed from every `exec` child.
#[cfg(windows)]
pub fn resolve_agent_from(start_pid: i32) -> Option<String> {
    let table = match process_table() {
        Some(t) => t,
        None => {
            // Degrade LOUDLY. A silent None here reads downstream as "a human
            // is at the keyboard" and waives grant enforcement.
            eprintln!(
                "(warning: could not enumerate processes — agent identity is unknown, \
                 so agent grant enforcement cannot be applied to this call)"
            );
            return None;
        }
    };

    let mut pid = start_pid as u32;
    for _ in 0..24 {
        let (ppid, name) = table.get(&pid)?;
        let lc = name.to_lowercase();
        if let Some(a) = KNOWN_AGENTS.iter().find(|a| lc.contains(**a)) {
            return Some((*a).to_string());
        }
        // pid 0 is the idle process; a ppid that is missing from the snapshot
        // is a dead parent (its pid may since have been recycled), so stop
        // rather than follow it. The loop cap also breaks any reuse cycle.
        if *ppid == 0 || *ppid == pid {
            break;
        }
        pid = *ppid;
    }
    None
}

/// pid → (ppid, image name) for every process visible to this user.
#[cfg(windows)]
fn process_table() -> Option<std::collections::HashMap<u32, (u32, String)>> {
    use windows::Win32::Foundation::CloseHandle;
    use windows::Win32::System::Diagnostics::ToolHelp::{
        CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
        TH32CS_SNAPPROCESS,
    };

    let mut map = std::collections::HashMap::new();
    unsafe {
        let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()?;
        let mut entry = PROCESSENTRY32W {
            dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
            ..Default::default()
        };
        if Process32FirstW(snap, &mut entry).is_err() {
            let _ = CloseHandle(snap);
            return None;
        }
        loop {
            let end = entry
                .szExeFile
                .iter()
                .position(|&c| c == 0)
                .unwrap_or(entry.szExeFile.len());
            let name = String::from_utf16_lossy(&entry.szExeFile[..end]);
            map.insert(
                entry.th32ProcessID,
                (entry.th32ParentProcessID, name),
            );
            if Process32NextW(snap, &mut entry).is_err() {
                break;
            }
        }
        let _ = CloseHandle(snap);
    }
    Some(map)
}

#[cfg(windows)]
pub fn resolve_agent() -> Option<String> {
    resolve_agent_from(std::process::id() as i32)
}

#[cfg(not(any(target_os = "macos", windows)))]
pub fn resolve_agent() -> Option<String> {
    None
}

#[cfg(not(any(target_os = "macos", windows)))]
pub fn resolve_agent_from(_start_pid: i32) -> Option<String> {
    None
}

#[cfg(all(test, windows))]
mod win_ancestry_tests {
    use super::*;

    #[test]
    fn snapshot_sees_this_process_and_its_parent() {
        let table = process_table().expect("Toolhelp snapshot");
        let me = std::process::id();
        let (ppid, name) = table.get(&me).expect("our own pid is in the snapshot");
        assert!(
            name.to_lowercase().contains("secrets"),
            "expected our own image name, got {name}"
        );
        assert!(*ppid != 0, "we should have a parent process");
        assert!(table.contains_key(ppid), "our parent should be in the snapshot too");
    }

    #[test]
    fn walk_terminates_and_never_invents_an_agent() {
        // The chain above a test runner is a shell / IDE, not a known agent —
        // and whatever it is, the walk must terminate rather than loop.
        let got = resolve_agent_from(std::process::id() as i32);
        if let Some(a) = &got {
            assert!(
                KNOWN_AGENTS.contains(&a.as_str()),
                "resolved '{a}', which is not a known agent id"
            );
        }
    }

    #[test]
    fn unknown_pid_resolves_to_none() {
        // A pid that cannot be in the snapshot must not resolve or panic.
        assert_eq!(resolve_agent_from(0x7FFF_FFFF), None);
    }
}