diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b4922d..0f7f778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ range may break in any release. ### Added +- **Agent anti-leak hardening (core dumps + ptrace).** On startup the agent now + disables core dumps and marks itself non-dumpable (which also blocks same-user + ptrace), so the in-memory user key / refresh token can't leak to a core file + or a debugger. Done via the `secmem-proc` crate (audited `rustix` syscalls, + keeps the agent's `#![forbid(unsafe_code)]`); best-effort — a sandbox that + restricts `setrlimit` logs a warning and the agent keeps running. PRD §12 M7 + hardening groundwork. (`mlock`/no-swap remains a tracked follow-up.) + - **Post-quantum transport (`pqc` feature, off by default).** A GPL-clean hybrid **X25519MLKEM768** key-exchange group is added to the rustls client config, so a TLS 1.3 handshake negotiates a post-quantum-secure secret when the server diff --git a/Cargo.lock b/Cargo.lock index a92ce19..ffb6ff6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1831,6 +1831,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix 1.1.4", +] + [[package]] name = "rustls" version = "0.23.40" @@ -1884,6 +1894,21 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secmem-proc" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca48b73b5429ef7a6e951cd0f215ef2c3c7eb60a852bda40fe41b6a6846422bb" +dependencies = [ + "anyhow", + "cfg-if", + "libc", + "rustix 1.1.4", + "rustix-linux-procfs", + "thiserror 2.0.18", + "windows", +] + [[package]] name = "semver" version = "1.0.28" @@ -2510,6 +2535,7 @@ dependencies = [ "arboard", "clap", "linux-keyutils", + "secmem-proc", "serde", "serde_json", "tempfile", @@ -2904,12 +2930,107 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -2994,6 +3115,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" diff --git a/README.md b/README.md index 57fc622..55d3a6d 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,10 @@ An optional **post-quantum transport** (`--features pqc`) prefers the hybrid X25519MLKEM768 key exchange on the HTTPS client, off by default — see [`docs/pqc.md`](docs/pqc.md). +The agent hardens itself at startup: it **disables core dumps and ptrace** (so +the in-memory user key can't leak to a core file or a debugger), on top of the +`0600` socket and zeroized key buffers. + ## Getting started ```sh diff --git a/crates/vault-agent/Cargo.toml b/crates/vault-agent/Cargo.toml index 8551d48..8e57aec 100644 --- a/crates/vault-agent/Cargo.toml +++ b/crates/vault-agent/Cargo.toml @@ -42,6 +42,9 @@ uuid = { workspace = true } url = { workspace = true } thiserror = { workspace = true } arboard = { workspace = true, optional = true } +# Process hardening: disable core dumps + ptrace at startup (safe rustix-backed +# API, keeps the crate's #![forbid(unsafe_code)]). See `harden.rs`. +secmem-proc = "0.3" vault-core = { path = "../vault-core" } vault-ipc = { path = "../vault-ipc" } vault-store = { path = "../vault-store" } diff --git a/crates/vault-agent/src/harden.rs b/crates/vault-agent/src/harden.rs new file mode 100644 index 0000000..e53f240 --- /dev/null +++ b/crates/vault-agent/src/harden.rs @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Process anti-leak hardening, run once at agent startup. +//! +//! The agent holds the unwrapped user key (and refresh token) in memory. This +//! locks down two ways that material could escape the process: +//! +//! - **Core dumps** — a crash must not write key bytes to a core file on disk. +//! - **ptrace** — another process running as the same user must not be able to +//! attach a debugger and read the agent's memory. +//! +//! Both are handled by [`secmem_proc::harden_process`] (lowers `RLIMIT_CORE` +//! and sets the process non-dumpable via the audited `rustix` syscall layer), +//! which keeps this crate's `#![forbid(unsafe_code)]` intact. +//! +//! This does **not** yet cover swap exposure (`mlock`); that's a tracked +//! follow-up. mlock is out of scope here. + +/// Harden the current process against core dumps and ptrace. Best-effort: if a +/// step fails (e.g. a sandbox that restricts `setrlimit`), log a warning and +/// keep running — a partial-hardening failure must never take the agent down. +pub fn harden_process() { + if let Err(e) = secmem_proc::harden_process() { + eprintln!("vault-agent: process hardening incomplete (continuing): {e}"); + } +} + +#[cfg(target_os = "linux")] +#[cfg(test)] +mod tests { + /// After hardening, the process must be marked non-dumpable — that's what + /// blocks core dumps and same-user ptrace. Where `/proc/self/status` + /// exposes the `Dumpable` field (a normal kernel) we assert it is `0`; some + /// sandboxed `/proc` mounts omit the field, in which case there is nothing + /// to check (the call still must not panic). `PR_SET_DUMPABLE(0)` is + /// unprivileged, so the assertion is deterministic where it applies. + #[test] + fn harden_sets_process_non_dumpable() { + super::harden_process(); + let status = std::fs::read_to_string("/proc/self/status").expect("read /proc/self/status"); + if let Some(dumpable) = status + .lines() + .find_map(|l| l.strip_prefix("Dumpable:")) + .map(str::trim) + { + assert_eq!( + dumpable, "0", + "process should be non-dumpable after hardening" + ); + } + } +} diff --git a/crates/vault-agent/src/main.rs b/crates/vault-agent/src/main.rs index 4e42361..8547a2a 100644 --- a/crates/vault-agent/src/main.rs +++ b/crates/vault-agent/src/main.rs @@ -21,6 +21,7 @@ use vault_ipc::default_socket_path; #[cfg(feature = "clipboard")] mod clipboard; +mod harden; mod server; mod session; mod state; @@ -97,6 +98,9 @@ enum ClipboardBackendArg { #[tokio::main(flavor = "current_thread")] async fn main() -> anyhow::Result<()> { let args = Args::parse(); + // Disable core dumps + ptrace before any key material can enter memory + // (in particular before `try_resume` reloads a key from the keyring). + harden::harden_process(); let path = pick_socket(args.socket)?; #[cfg(feature = "clipboard")] let agent = {