secrets-vault 2.0.0

AES-256-GCM encrypted key-value vault with PBKDF2 key derivation. Store API keys and tokens securely instead of plaintext dotfiles.
Documentation
//! Session-unlock broker v2 — a KEY SERVER, not a passphrase dispenser
//! (QVLT2_SPEC.md §6). ONE Touch ID starts a short-lived daemon; callers ask
//! for individual values (`GET <project> <key>`) and the broker enforces the
//! grant registry per request, server-side. The passphrase is read from the
//! child's stdin pipe, used once to derive the master secret, and zeroized —
//! it never crosses the socket in any form, and neither does the master
//! secret. What crosses is at most ONE decrypted value per authorized request.
//!
//! Security boundary: the socket lives at `~/.secrets/session.sock` with 0600
//! perms inside the 0700 secrets dir — only the owning uid can connect (the
//! same FS-level guarantee ssh-agent relies on). Caller identity comes from
//! the kernel's `LOCAL_PEERTOKEN` audit token (pid + pidversion — closes the
//! pid-reuse race `LOCAL_PEEREPID` would leave), then the same process-
//! ancestry agent resolution `exec` uses, run on the PEER's pid. The caller's
//! own claims are never consulted. Residual risk (spec §3.1): ancestry is
//! same-uid-spoofable — the broker narrows blast radius and leaves an audit
//! line; the uid boundary remains the OS trust line.
//!
//! Protocol (spec §6.1):
//!   `GET <project> <key>\n` → `OK <len>\n<len bytes>` | `ERR denied\n` |
//!                             `ERR unknown-key\n` | `ERR scheme\n`
//!   `END\n`                 → connection closed, broker exits
//!   bare `GET\n` (legacy v1 client) → EMPTY response + close, so old clients
//!   fall through to their Touch ID path instead of misparsing an error
//!   string as a passphrase.
//! Error ordering is normative: a caller without a grant gets `ERR denied`
//! for EVERY key, existing or not — the broker is not an existence oracle.

use std::io::{Read, Write};
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

use zeroize::Zeroizing;

use secrets_vault::{is_valid_key, is_valid_project, v2_salt, MasterSecret, VaultError, VaultReader};

use crate::registry;

/// Socket path — inside the (0700) secrets dir so only the owner can reach it.
pub fn socket_path(secrets_dir: &Path) -> PathBuf {
    secrets_dir.join("session.sock")
}

fn log_path(secrets_dir: &Path) -> PathBuf {
    secrets_dir.join("session.log")
}

/// CLIENT: ask a running broker for one value. Returns the plaintext value if
/// the broker exists AND the grant checks pass server-side, else None (caller
/// falls back to the Touch ID Keychain path). Any error is a silent None — a
/// missing/expired/denying broker is a normal case, never fatal.
pub fn request_value(
    secrets_dir: &Path,
    project: &str,
    key: &str,
) -> Option<Zeroizing<Vec<u8>>> {
    let path = socket_path(secrets_dir);
    let mut stream = UnixStream::connect(&path).ok()?;
    stream.set_read_timeout(Some(Duration::from_secs(5))).ok()?;
    stream
        .write_all(format!("GET {project} {key}\n").as_bytes())
        .ok()?;
    let mut buf = Zeroizing::new(Vec::new());
    stream.read_to_end(&mut buf).ok()?;
    // Response: b"OK <len>\n" ++ <len bytes>. Anything else → None.
    let nl = buf.iter().position(|&b| b == b'\n')?;
    let header = std::str::from_utf8(&buf[..nl]).ok()?;
    let len: usize = header.strip_prefix("OK ")?.parse().ok()?;
    let body = &buf[nl + 1..];
    if body.len() != len {
        return None;
    }
    Some(Zeroizing::new(body.to_vec()))
}

/// CLIENT: ask a running broker to shut down NOW (used by `secrets lock` and
/// `secrets rekey`). Best-effort; also unlinks the socket so a wedged daemon
/// can't be reached.
pub fn end(secrets_dir: &Path) {
    let path = socket_path(secrets_dir);
    if let Ok(mut s) = UnixStream::connect(&path) {
        let _ = s.write_all(b"END\n");
    }
    let _ = std::fs::remove_file(&path);
}

/// Kernel-attested identity of a connected peer.
struct Peer {
    pid: i32,
    pidversion: i32,
    euid: u32,
}

#[cfg(target_os = "macos")]
fn peer_identity(stream: &UnixStream) -> Option<Peer> {
    use std::os::unix::io::AsRawFd;

    // audit_token_t (mach): 8 u32s. Read via getsockopt(SOL_LOCAL,
    // LOCAL_PEERTOKEN) — captured by the kernel at connect time — and field-
    // extracted with libbsm's PUBLIC accessors (never hand-indexed).
    #[repr(C)]
    #[derive(Clone, Copy)]
    struct AuditToken {
        val: [u32; 8],
    }
    const SOL_LOCAL: libc::c_int = 0; // sys/un.h
    const LOCAL_PEERTOKEN: libc::c_int = 0x006; // sys/un.h

    #[link(name = "bsm")]
    extern "C" {
        fn audit_token_to_pid(t: AuditToken) -> libc::pid_t;
        fn audit_token_to_pidversion(t: AuditToken) -> libc::c_int;
        fn audit_token_to_euid(t: AuditToken) -> libc::uid_t;
    }

    let mut token = AuditToken { val: [0; 8] };
    let mut len = std::mem::size_of::<AuditToken>() as libc::socklen_t;
    let rc = unsafe {
        libc::getsockopt(
            stream.as_raw_fd(),
            SOL_LOCAL,
            LOCAL_PEERTOKEN,
            &mut token as *mut _ as *mut libc::c_void,
            &mut len,
        )
    };
    if rc != 0 || len as usize != std::mem::size_of::<AuditToken>() {
        return None;
    }
    unsafe {
        Some(Peer {
            pid: audit_token_to_pid(token),
            pidversion: audit_token_to_pidversion(token),
            euid: audit_token_to_euid(token),
        })
    }
}

#[cfg(not(target_os = "macos"))]
fn peer_identity(_stream: &UnixStream) -> Option<Peer> {
    // No kernel peer attestation wired on this platform yet → every GET is
    // denied (fail closed). The 0600 socket still gates by uid.
    None
}

/// Best-effort append to the audit log (names and verdicts only — NEVER values).
fn audit(secrets_dir: &Path, line: &str) {
    let ts = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    let path = log_path(secrets_dir);
    #[cfg(unix)]
    let file = {
        use std::os::unix::fs::OpenOptionsExt;
        std::fs::OpenOptions::new()
            .create(true)
            .append(true)
            .mode(0o600)
            .open(&path)
    };
    #[cfg(not(unix))]
    let file = std::fs::OpenOptions::new().create(true).append(true).open(&path);
    if let Ok(mut f) = file {
        let _ = writeln!(f, "{ts} {line}");
    }
}

enum Verdict {
    Ok(Zeroizing<Vec<u8>>),
    Denied,
    UnknownKey,
    Scheme,
}

/// The per-request enforcement pipeline (spec §6.2, normative order):
/// kernel peer identity → euid gate → ancestry agent resolution on the PEER's
/// pid → registry grant → declared-key manifest → single-record decrypt.
#[allow(clippy::too_many_arguments)]
fn handle_get(
    secrets_dir: &Path,
    vault_path: &Path,
    master: &MasterSecret,
    registry_key: &[u8; 32],
    peer: Option<Peer>,
    project: &str,
    key: &str,
) -> Verdict {
    if !is_valid_project(project) || !is_valid_key(key) {
        return Verdict::Denied;
    }
    let Some(peer) = peer else {
        return Verdict::Denied;
    };
    if peer.euid != unsafe { libc::geteuid() } {
        return Verdict::Denied;
    }
    // pidversion rides in the token; a live re-verification would need a
    // private proc_info flavor, so instead the ancestry walk happens
    // immediately and the token's (pid, pidversion) goes to the audit log.
    let Some(agent) = registry::resolve_agent_from(peer.pid) else {
        audit(
            secrets_dir,
            &format!(
                "DENY pid={} pidv={} (no agent) {project}/{key}",
                peer.pid, peer.pidversion
            ),
        );
        return Verdict::Denied;
    };
    let reg = match registry::Registry::load_raw(secrets_dir, registry_key) {
        Ok(r) => r,
        Err(_) => return Verdict::Denied,
    };
    if reg.grant_for(&agent, project, registry::now()).is_none() {
        audit(secrets_dir, &format!("DENY agent={agent} (no grant) {project}/{key}"));
        return Verdict::Denied;
    }
    // Declared-key manifest: when the registry records the project's key set,
    // requests outside it are denied (NOT unknown-key — no namespace oracle).
    if let Some(meta) = reg.projects.get(project) {
        if !meta.keys.is_empty() && !meta.keys.iter().any(|k| k == key) {
            audit(
                secrets_dir,
                &format!("DENY agent={agent} (outside manifest) {project}/{key}"),
            );
            return Verdict::Denied;
        }
    }

    // Re-read the vault per request — a `set` during the session window is
    // picked up naturally (the salt is stable across saves, spec §5.1).
    let data = match std::fs::read(vault_path) {
        Ok(d) => d,
        Err(_) => return Verdict::Denied,
    };
    let reader = match VaultReader::open(data, master) {
        Ok(r) => r,
        Err(_) => return Verdict::Denied,
    };
    let storage = format!("{project}/{key}");
    match reader.decrypt_one(master, &storage) {
        Ok(value) => {
            audit(secrets_dir, &format!("SERVE agent={agent} {project}/{key}"));
            Verdict::Ok(value)
        }
        Err(VaultError::NotFound) => {
            audit(secrets_dir, &format!("UNKNOWN agent={agent} {project}/{key}"));
            Verdict::UnknownKey
        }
        Err(VaultError::UnknownScheme(_)) => Verdict::Scheme,
        Err(_) => Verdict::Denied,
    }
}

/// DAEMON: derive keys, zeroize the passphrase, then answer per-key requests
/// until the lifetime expires or an `END` arrives. Called ONLY by the hidden
/// `__session-serve` subcommand in the detached child; `pass` was read from
/// the child's stdin (pipe), never argv/env. Blocks until exit, then removes
/// the socket.
pub fn serve(secrets_dir: &Path, minutes: u64, pass: Zeroizing<String>) -> Result<(), String> {
    let vault_path = secrets_dir.join("vault.qvlt");

    // Fail closed on anything but a healthy v2 vault — a v1 vault must never
    // revive the passphrase-dispenser behavior (spec §8).
    let data = std::fs::read(&vault_path).map_err(|e| format!("read vault: {e}"))?;
    let salt = v2_salt(&data).map_err(|e| format!("not a v2 vault: {e}"))?;
    let master = MasterSecret::derive(&pass, &salt);
    drop(pass); // Zeroizing: the passphrase's lifetime ends HERE (G4).
    let registry_key_z = master.registry_key();
    let registry_key: &[u8; 32] = &registry_key_z;
    // Prove the derivation before serving anything (wrong passphrase → exit).
    VaultReader::open(data, &master).map_err(|e| format!("vault open: {e}"))?;

    let path = socket_path(secrets_dir);
    // Fresh socket: unlink a stale one first (a prior daemon that died hard).
    let _ = std::fs::remove_file(&path);
    let listener =
        UnixListener::bind(&path).map_err(|e| format!("bind {}: {e}", path.display()))?;
    // 0600 BEFORE we accept anything — owner-only is the whole security model.
    std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
        .map_err(|e| format!("chmod socket: {e}"))?;

    audit(secrets_dir, &format!("START lifetime={minutes}m"));
    let deadline = Instant::now() + Duration::from_secs(minutes.saturating_mul(60));
    listener
        .set_nonblocking(true)
        .map_err(|e| format!("nonblocking: {e}"))?;

    'outer: loop {
        if Instant::now() >= deadline {
            break;
        }
        match listener.accept() {
            Ok((mut stream, _)) => {
                // Capture the kernel-attested identity FIRST — before reading
                // anything the peer controls.
                let peer = peer_identity(&stream);
                let _ = stream.set_nonblocking(false);
                let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));

                // One bounded request line ("GET " + 256 + 1 + 256 + "\n").
                let mut req = Vec::with_capacity(64);
                let mut byte = [0u8; 1];
                while req.len() < 1024 {
                    match stream.read(&mut byte) {
                        Ok(1) if byte[0] == b'\n' => break,
                        Ok(1) => req.push(byte[0]),
                        _ => break,
                    }
                }
                let line = String::from_utf8_lossy(&req);
                let mut parts = line.split_whitespace();
                match (parts.next(), parts.next(), parts.next(), parts.next()) {
                    (Some("END"), None, None, None) => {
                        audit(secrets_dir, "END (requested)");
                        break 'outer;
                    }
                    (Some("GET"), Some(project), Some(key), None) => {
                        let verdict = handle_get(
                            secrets_dir,
                            &vault_path,
                            &master,
                            registry_key,
                            peer,
                            project,
                            key,
                        );
                        match verdict {
                            Verdict::Ok(value) => {
                                let mut out =
                                    Zeroizing::new(Vec::with_capacity(16 + value.len()));
                                out.extend_from_slice(format!("OK {}\n", value.len()).as_bytes());
                                out.extend_from_slice(&value);
                                let _ = stream.write_all(&out);
                            }
                            Verdict::Denied => {
                                let _ = stream.write_all(b"ERR denied\n");
                            }
                            Verdict::UnknownKey => {
                                let _ = stream.write_all(b"ERR unknown-key\n");
                            }
                            Verdict::Scheme => {
                                let _ = stream.write_all(b"ERR scheme\n");
                            }
                        }
                    }
                    // Bare `GET` = legacy v1 client expecting the passphrase.
                    // Close with an EMPTY response: v1 clients treat that as
                    // "no broker" and fall through to Touch ID (spec §6.1).
                    (Some("GET"), None, None, None) => {
                        audit(secrets_dir, "LEGACY-GET (empty close)");
                    }
                    _ => {
                        let _ = stream.write_all(b"ERR denied\n");
                    }
                }
                // stream drops → close.
            }
            Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
                std::thread::sleep(Duration::from_millis(200));
            }
            Err(_) => std::thread::sleep(Duration::from_millis(200)),
        }
    }
    audit(secrets_dir, "STOP");
    let _ = std::fs::remove_file(&path);
    Ok(())
}

/// Detach from the controlling terminal so the broker outlives the shell that
/// started it (the drain may run long after the launching command returns).
/// `setsid` makes us a new session leader with no controlling tty.
pub fn detach() {
    unsafe {
        libc::setsid();
    }
}