feat(agent): cak_ at-rest credential store (SPEC-016 Phase B item 4)
Store the per-machine cak_ with BOTH layers Mike locked: DPAPI-machine encryption (CryptProtectData with CRYPTPROTECT_LOCAL_MACHINE — a copied blob is inert off the box) inside a SYSTEM/Administrators-only ACL'd file at %ProgramData%\GuruConnect\credentials\agent.cak. The directory + file ACL is hardened via icacls (/inheritance:r + grant to the well-known SIDs *S-1-5-18 and *S-1-5-32-544, locale-independent) — auditable, with far less unsafe FFI than building a registry-key security descriptor by hand. Co-locates with the existing %ProgramData%\GuruConnect config/seed dir. Provides store_cak / load_cak / clear_cak. store_cak writes atomically (temp file + rename in the locked dir). load_cak treats a present-but- undecryptable blob as a hard error (tamper / cross-machine copy) rather than silently re-enrolling over it. The plaintext is never logged; the transient plaintext copy is scrubbed after encryption. DPAPI output blobs are LocalFree'd. Enables the Win32_Security_Cryptography windows feature. Round-trip unit tests cover encrypt/decrypt recovery across lengths and that a tampered blob fails to decrypt (DPAPI authenticates its blobs). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
325
agent/src/credential_store.rs
Normal file
325
agent/src/credential_store.rs
Normal file
@@ -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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<Option<String>> {
|
||||
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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user