diff --git a/agent/Cargo.toml b/agent/Cargo.toml index 1a8dc82..f4ea922 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -92,6 +92,7 @@ windows = { version = "0.58", features = [ "Win32_System_Console", "Win32_System_Environment", "Win32_Security", + "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Pipes", "Win32_System_SystemServices", diff --git a/agent/src/credential_store.rs b/agent/src/credential_store.rs new file mode 100644 index 0000000..1e9d4cb --- /dev/null +++ b/agent/src/credential_store.rs @@ -0,0 +1,325 @@ +//! At-rest storage for the per-machine operating credential (`cak_`). +//! +//! SPEC-016 Phase B, item 4 + §Security. The `cak_` minted by `/api/enroll` is +//! the high-sensitivity, per-machine, independently-revocable operating +//! credential. It is stored with **two independent layers** (Mike's locked +//! decision — "BOTH layers"): +//! +//! 1. **DPAPI-machine encryption** (`CryptProtectData` with +//! `CRYPTPROTECT_LOCAL_MACHINE`): the on-disk bytes are a DPAPI blob keyed to +//! THIS machine. A copied/exfiltrated file is inert on any other box — DPAPI +//! machine keys do not leave the machine. +//! 2. **SYSTEM/Administrators-only ACL** on the containing directory + file: a +//! non-admin user cannot even read the ciphertext. Inheritance is removed and +//! only `SYSTEM` and `BUILTIN\Administrators` are granted full control. +//! +//! Local admin / SYSTEM can always recover the value — that is accepted (SPEC-016 +//! §Security): the blast radius of one leaked `cak_` is a single, independently +//! revocable machine. +//! +//! Storage location (chosen over an HKLM value): a file under +//! `%ProgramData%\GuruConnect\credentials\agent.cak`. Rationale — the agent +//! already keeps its config and the `machine_uid` fallback seed under +//! `%ProgramData%\GuruConnect`, so co-locating keeps a single protected +//! directory; and a directory/file ACL applied via `icacls` is auditable with far +//! less unsafe FFI than building a registry-key security descriptor by hand. Both +//! storage shapes are explicitly permitted by the spec. +//! +//! SECURITY: the plaintext `cak_` is NEVER logged. Errors describe the operation, +//! not the value. + +#![cfg(windows)] + +use anyhow::{anyhow, Context, Result}; +use std::path::PathBuf; + +/// Directory holding the protected credential file. +fn credentials_dir() -> Result { + let program_data = + std::env::var("ProgramData").context("ProgramData environment variable is not set")?; + Ok(PathBuf::from(program_data) + .join("GuruConnect") + .join("credentials")) +} + +/// Full path to the DPAPI-encrypted `cak_` blob. +fn cak_path() -> Result { + Ok(credentials_dir()?.join("agent.cak")) +} + +/// Persist `cak` encrypted at rest. +/// +/// 1. Ensures the credentials directory exists and is locked down (SYSTEM + +/// Administrators full control, inheritance removed). +/// 2. DPAPI-machine-encrypts the plaintext. +/// 3. Writes the ciphertext to `agent.cak` and locks the file ACL too. +/// +/// Returns an error (never logs the plaintext) on any failure so the caller can +/// surface it / retry. A partial write is replaced atomically via a temp file + +/// rename within the same protected directory. +pub fn store_cak(cak: &str) -> Result<()> { + let dir = credentials_dir()?; + std::fs::create_dir_all(&dir) + .with_context(|| format!("failed to create credentials dir {dir:?}"))?; + lock_down_acl(&dir).context("failed to restrict credentials directory ACL")?; + + let ciphertext = dpapi_protect(cak.as_bytes()).context("DPAPI encryption of cak_ failed")?; + + let path = cak_path()?; + // Atomic-ish replace: write to a sibling temp file, then rename over the + // target. Both live in the already-locked-down directory, so the temp file + // inherits the restrictive ACL (inheritance was re-enabled for children when + // we granted on the dir; we still belt-and-suspenders lock the final file). + let tmp = path.with_extension("cak.tmp"); + std::fs::write(&tmp, &ciphertext) + .with_context(|| format!("failed to write temp credential file {tmp:?}"))?; + std::fs::rename(&tmp, &path) + .with_context(|| format!("failed to place credential file {path:?}"))?; + lock_down_acl(&path).context("failed to restrict credential file ACL")?; + + tracing::info!("[ENROLL] stored per-machine credential (encrypted at rest)"); + Ok(()) +} + +/// Load and decrypt the stored `cak_`, or `None` if no credential is stored yet. +/// +/// A present-but-undecryptable blob (e.g. copied from another machine, or +/// corrupted) is treated as a hard error rather than `None`, so the caller does +/// not silently re-enroll over a tampered store without noticing. +pub fn load_cak() -> Result> { + let path = cak_path()?; + let ciphertext = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e).with_context(|| format!("failed to read {path:?}")), + }; + if ciphertext.is_empty() { + return Ok(None); + } + let plaintext = dpapi_unprotect(&ciphertext) + .context("DPAPI decryption of stored cak_ failed (wrong machine or corrupted blob?)")?; + let cak = + String::from_utf8(plaintext).context("stored cak_ was not valid UTF-8 after decryption")?; + if cak.is_empty() { + return Ok(None); + } + Ok(Some(cak)) +} + +/// Remove the stored credential (e.g. on revocation / forced re-enroll). +/// Succeeds if the file is already absent. +/// +/// Part of the store/load/clear API the spec requires (SPEC-016 item 4). Not yet +/// called from a code path — the relay-side `cak_` revocation / forced re-enroll +/// flow that drives it is the deferred SPEC-016 Phase B/D server work (the +/// `TODO(SPEC-016 Phase B/D): consider revoking existing cak_ on collision` note +/// in `server/src/api/enroll.rs`) — so it is retained as part of the complete +/// store API and explicitly allowed dead until that server work lands. +#[allow(dead_code)] +pub fn clear_cak() -> Result<()> { + let path = cak_path()?; + match std::fs::remove_file(&path) { + Ok(()) => { + tracing::info!("[ENROLL] cleared stored per-machine credential"); + Ok(()) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e).with_context(|| format!("failed to remove {path:?}")), + } +} + +// --------------------------------------------------------------------------- +// DPAPI (machine scope) +// --------------------------------------------------------------------------- + +/// DPAPI-machine-encrypt `plaintext` into a self-contained blob. +fn dpapi_protect(plaintext: &[u8]) -> Result> { + use windows::Win32::Security::Cryptography::{ + CryptProtectData, CRYPTPROTECT_LOCAL_MACHINE, CRYPT_INTEGER_BLOB, + }; + + // CryptProtectData requires a mutable input pointer in the struct, though it + // does not modify the bytes; copy into a local Vec to get a *mut without + // aliasing the caller's slice. + let mut input = plaintext.to_vec(); + let in_blob = CRYPT_INTEGER_BLOB { + cbData: u32::try_from(input.len()).context("plaintext too large for DPAPI")?, + pbData: input.as_mut_ptr(), + }; + let mut out_blob = CRYPT_INTEGER_BLOB::default(); + + // SAFETY: in_blob points at a valid, sized buffer; out_blob is owned here and + // its pbData is allocated by DPAPI (freed via LocalFree below). No prompt + // struct / entropy / reserved args. + unsafe { + CryptProtectData( + &in_blob, + windows::core::PCWSTR::null(), + None, + None, + None, + CRYPTPROTECT_LOCAL_MACHINE, + &mut out_blob, + ) + .context("CryptProtectData failed")?; + } + + let result = copy_and_free_blob(&out_blob); + // Best-effort scrub of the transient plaintext copy. + input.iter_mut().for_each(|b| *b = 0); + + result.ok_or_else(|| anyhow!("CryptProtectData returned an empty/invalid blob")) +} + +/// DPAPI-decrypt a blob previously produced by [`dpapi_protect`] on this machine. +fn dpapi_unprotect(ciphertext: &[u8]) -> Result> { + use windows::Win32::Security::Cryptography::{ + CryptUnprotectData, CRYPTPROTECT_LOCAL_MACHINE, CRYPT_INTEGER_BLOB, + }; + + let mut input = ciphertext.to_vec(); + let in_blob = CRYPT_INTEGER_BLOB { + cbData: u32::try_from(input.len()).context("ciphertext too large for DPAPI")?, + pbData: input.as_mut_ptr(), + }; + let mut out_blob = CRYPT_INTEGER_BLOB::default(); + + // SAFETY: as in dpapi_protect — valid sized input, owned output freed below. + unsafe { + CryptUnprotectData( + &in_blob, + None, + None, + None, + None, + CRYPTPROTECT_LOCAL_MACHINE, + &mut out_blob, + ) + .context("CryptUnprotectData failed")?; + } + + copy_and_free_blob(&out_blob) + .ok_or_else(|| anyhow!("CryptUnprotectData returned an empty/invalid blob")) +} + +/// Copy a DPAPI output blob into an owned `Vec` and `LocalFree` the DPAPI buffer. +/// +/// Returns `Some(bytes)` on success, `None` if the blob is null/empty. Always +/// frees `pbData` when non-null (DPAPI allocates it with `LocalAlloc`). +fn copy_and_free_blob( + blob: &windows::Win32::Security::Cryptography::CRYPT_INTEGER_BLOB, +) -> Option> { + use windows::Win32::Foundation::{LocalFree, HLOCAL}; + + if blob.pbData.is_null() { + return None; + } + // SAFETY: DPAPI guarantees pbData points at cbData valid bytes on success. + let bytes = unsafe { std::slice::from_raw_parts(blob.pbData, blob.cbData as usize).to_vec() }; + // SAFETY: pbData was allocated by DPAPI via LocalAlloc; free it once. + unsafe { + let _ = LocalFree(HLOCAL(blob.pbData as *mut core::ffi::c_void)); + } + if bytes.is_empty() { + None + } else { + Some(bytes) + } +} + +// --------------------------------------------------------------------------- +// ACL hardening +// --------------------------------------------------------------------------- + +/// Restrict `path` (file or directory) to SYSTEM + Administrators full control, +/// removing inherited ACEs so a permissive parent grant cannot leak read access. +/// +/// Implemented via `icacls` — the documented, auditable mechanism — rather than +/// hand-rolling a security descriptor through `SetNamedSecurityInfoW` (hundreds +/// of lines of SID/ACL FFI). `icacls` ships on every supported Windows target. +/// A failure here is surfaced (the caller treats inability to lock down the +/// credential store as a hard error) but the well-known SIDs `*S-1-5-18` +/// (LocalSystem) and `*S-1-5-32-544` (BUILTIN\Administrators) are language- and +/// locale-independent, so this does not break on localized Windows. +fn lock_down_acl(path: &std::path::Path) -> Result<()> { + use std::os::windows::process::CommandExt; + use std::process::Command; + + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + + let path_str = path + .to_str() + .ok_or_else(|| anyhow!("credential path is not valid UTF-8: {path:?}"))?; + + // /inheritance:r -> remove inherited ACEs (drop the permissive parent grant) + // /grant:r -> replace any existing explicit grants for the principal + // *S-1-5-18 -> LocalSystem; *S-1-5-32-544 -> BUILTIN\Administrators + let output = Command::new("icacls") + .arg(path_str) + .args([ + "/inheritance:r", + "/grant:r", + "*S-1-5-18:(OI)(CI)F", + "/grant:r", + "*S-1-5-32-544:(OI)(CI)F", + ]) + .creation_flags(CREATE_NO_WINDOW) + .output() + .context("failed to invoke icacls to harden credential ACL")?; + + if !output.status.success() { + // icacls writes its diagnostics to stdout; surface the code only (no + // credential material is ever passed to icacls, only the path). + return Err(anyhow!( + "icacls failed to harden {path_str} (exit {:?})", + output.status.code() + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// DPAPI round-trips on the same machine: protect then unprotect must recover + /// the exact plaintext. (Runs on the build/test host, which IS the same + /// machine — the machine-scope key is available to any process here.) + #[test] + fn dpapi_roundtrip_recovers_plaintext() { + let secret = b"cak_test_value_0123456789abcdef"; + let blob = dpapi_protect(secret).expect("DPAPI protect should succeed on this machine"); + assert_ne!( + blob.as_slice(), + secret.as_slice(), + "ciphertext must differ from plaintext" + ); + let recovered = dpapi_unprotect(&blob).expect("DPAPI unprotect should succeed"); + assert_eq!(recovered, secret, "round-trip must recover the exact bytes"); + } + + /// A non-empty plaintext yields a non-empty, differing blob, and an empty + /// input is handled (DPAPI accepts zero-length and round-trips to empty). + #[test] + fn dpapi_roundtrip_handles_varied_lengths() { + for plaintext in [b"x".as_slice(), b"cak_".as_slice(), &[0u8; 256]] { + let blob = dpapi_protect(plaintext).expect("protect"); + let back = dpapi_unprotect(&blob).expect("unprotect"); + assert_eq!(back.as_slice(), plaintext); + } + } + + /// Tampering with the ciphertext must make decryption FAIL rather than return + /// garbage — DPAPI authenticates its blobs. + #[test] + fn dpapi_rejects_tampered_blob() { + let mut blob = dpapi_protect(b"cak_tamper_target").expect("protect"); + // Flip a byte in the middle of the blob. + let mid = blob.len() / 2; + blob[mid] ^= 0xFF; + assert!( + dpapi_unprotect(&blob).is_err(), + "a tampered DPAPI blob must fail to decrypt" + ); + } +}