Compare commits
6 Commits
89c3718266
...
4c49b73a71
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c49b73a71 | |||
| 367906bd54 | |||
| 52477e4c4a | |||
| 87c6e17d4a | |||
| 6a000d012f | |||
| d0b8db070f |
@@ -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",
|
||||
|
||||
@@ -16,18 +16,39 @@ use uuid::Uuid;
|
||||
const MAGIC_MARKER: &[u8] = b"GURUCONFIG";
|
||||
|
||||
/// Embedded configuration data (appended to executable)
|
||||
///
|
||||
/// SPEC-016 Phase B: a managed-install config now carries the per-site
|
||||
/// `enrollment_key` + `site_code` so the agent can self-register on first run.
|
||||
/// The legacy `api_key` is retained (defaulted) for backward-compat with older
|
||||
/// pre-enrollment installers; a fresh site installer carries only the enrollment
|
||||
/// credentials and the agent obtains its per-machine `cak_` via `/api/enroll`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmbeddedConfig {
|
||||
/// Server WebSocket URL
|
||||
pub server_url: String,
|
||||
/// API key for authentication
|
||||
pub api_key: String,
|
||||
/// DEPRECATED shared/legacy API key for authentication. Optional — a
|
||||
/// SPEC-016 site installer omits it and enrolls for a per-machine `cak_`.
|
||||
#[serde(default)]
|
||||
pub api_key: Option<String>,
|
||||
/// Per-site enrollment key (`cek_`), the low-sensitivity registration gate
|
||||
/// (SPEC-016 §Security). Presented to `/api/enroll`; never logged.
|
||||
#[serde(default)]
|
||||
pub enrollment_key: Option<String>,
|
||||
/// Per-site code identifying which site this installer enrolls into.
|
||||
#[serde(default)]
|
||||
pub site_code: Option<String>,
|
||||
/// Company/organization name
|
||||
#[serde(default)]
|
||||
pub company: Option<String>,
|
||||
/// Site/location name
|
||||
#[serde(default)]
|
||||
pub site: Option<String>,
|
||||
/// Department label (reserved — SPEC-007 AgentStatus parity).
|
||||
#[serde(default)]
|
||||
pub department: Option<String>,
|
||||
/// Device-type label (reserved — SPEC-007 AgentStatus parity).
|
||||
#[serde(default)]
|
||||
pub device_type: Option<String>,
|
||||
/// Tags for categorization
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
@@ -52,9 +73,28 @@ pub struct Config {
|
||||
/// Server WebSocket URL (e.g., wss://connect.example.com/ws)
|
||||
pub server_url: String,
|
||||
|
||||
/// Agent API key for authentication
|
||||
/// Operating credential used to authenticate the persistent WS connection.
|
||||
///
|
||||
/// SPEC-016 Phase B: the AUTHORITATIVE credential is a per-machine `cak_`
|
||||
/// obtained at first-run enrollment and stored encrypted at rest (see
|
||||
/// [`crate::credential_store`]); it is loaded into this field before connect.
|
||||
/// A non-empty value carried in config is the DEPRECATED shared/legacy
|
||||
/// `api_key`, kept only for transition compatibility. Empty means "not yet
|
||||
/// enrolled / no credential" — the run-mode wiring must enroll first.
|
||||
#[serde(default)]
|
||||
pub api_key: String,
|
||||
|
||||
/// Per-site enrollment key (`cek_`) — present only for a not-yet-enrolled
|
||||
/// managed install. Never persisted to the on-disk TOML (it is install-time
|
||||
/// material, delivered by the site wrapper); never logged.
|
||||
#[serde(skip)]
|
||||
pub enrollment_key: Option<String>,
|
||||
|
||||
/// Per-site code identifying which site to enroll into (paired with
|
||||
/// `enrollment_key`). Not persisted to the on-disk TOML.
|
||||
#[serde(skip)]
|
||||
pub site_code: Option<String>,
|
||||
|
||||
/// Unique agent identifier (generated on first run)
|
||||
#[serde(default = "generate_agent_id")]
|
||||
pub agent_id: String,
|
||||
@@ -70,6 +110,14 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub site: Option<String>,
|
||||
|
||||
/// Department label (reserved — SPEC-007 AgentStatus parity).
|
||||
#[serde(default)]
|
||||
pub department: Option<String>,
|
||||
|
||||
/// Device-type label (reserved — SPEC-007 AgentStatus parity).
|
||||
#[serde(default)]
|
||||
pub device_type: Option<String>,
|
||||
|
||||
/// Tags for categorization (from embedded config)
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
@@ -91,6 +139,25 @@ fn generate_agent_id() -> String {
|
||||
Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// Layer SPEC-016 enrollment material from the environment onto a `Config`.
|
||||
///
|
||||
/// `GURUCONNECT_ENROLLMENT_KEY` / `GURUCONNECT_SITE_CODE` only OVERRIDE when set
|
||||
/// and non-empty, so embedded/install-time values already present on the config
|
||||
/// are preserved. Used by the file and env load paths (the embedded path already
|
||||
/// carries these from the install blob).
|
||||
fn apply_enrollment_env(config: &mut Config) {
|
||||
if let Ok(v) = std::env::var("GURUCONNECT_ENROLLMENT_KEY") {
|
||||
if !v.is_empty() {
|
||||
config.enrollment_key = Some(v);
|
||||
}
|
||||
}
|
||||
if let Ok(v) = std::env::var("GURUCONNECT_SITE_CODE") {
|
||||
if !v.is_empty() {
|
||||
config.site_code = Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CaptureConfig {
|
||||
/// Target frames per second (1-60)
|
||||
@@ -317,18 +384,26 @@ impl Config {
|
||||
info!("Using embedded configuration");
|
||||
let config = Config {
|
||||
server_url: embedded.server_url,
|
||||
api_key: embedded.api_key,
|
||||
// Legacy/shared api_key if the installer carried one; empty
|
||||
// otherwise (the SPEC-016 path enrolls for a per-machine cak_).
|
||||
api_key: embedded.api_key.unwrap_or_default(),
|
||||
enrollment_key: embedded.enrollment_key,
|
||||
site_code: embedded.site_code,
|
||||
agent_id: generate_agent_id(),
|
||||
hostname_override: None,
|
||||
company: embedded.company,
|
||||
site: embedded.site,
|
||||
department: embedded.department,
|
||||
device_type: embedded.device_type,
|
||||
tags: embedded.tags,
|
||||
support_code: None,
|
||||
capture: CaptureConfig::default(),
|
||||
encoding: EncodingConfig::default(),
|
||||
};
|
||||
|
||||
// Save to file for persistence (so agent_id is preserved)
|
||||
// Save to file for persistence (so agent_id is preserved). The
|
||||
// #[serde(skip)] enrollment fields are intentionally NOT written to
|
||||
// the on-disk TOML — they are install-time material only.
|
||||
let _ = config.save();
|
||||
return Ok(config);
|
||||
}
|
||||
@@ -349,8 +424,12 @@ impl Config {
|
||||
let _ = config.save();
|
||||
}
|
||||
|
||||
// support_code is always None when loading from file (set via CLI)
|
||||
// support_code is always None when loading from file (set via CLI).
|
||||
config.support_code = None;
|
||||
// The enrollment fields are #[serde(skip)], so a file never carries
|
||||
// them; layer them in from the environment for testing / a
|
||||
// file-delivered managed install that supplies them out-of-band.
|
||||
apply_enrollment_env(&mut config);
|
||||
|
||||
return Ok(config);
|
||||
}
|
||||
@@ -365,18 +444,23 @@ impl Config {
|
||||
let agent_id =
|
||||
std::env::var("GURUCONNECT_AGENT_ID").unwrap_or_else(|_| generate_agent_id());
|
||||
|
||||
let config = Config {
|
||||
let mut config = Config {
|
||||
server_url,
|
||||
api_key,
|
||||
enrollment_key: None,
|
||||
site_code: None,
|
||||
agent_id,
|
||||
hostname_override: std::env::var("GURUCONNECT_HOSTNAME").ok(),
|
||||
company: None,
|
||||
site: None,
|
||||
department: None,
|
||||
device_type: None,
|
||||
tags: Vec::new(),
|
||||
support_code: None,
|
||||
capture: CaptureConfig::default(),
|
||||
encoding: EncodingConfig::default(),
|
||||
};
|
||||
apply_enrollment_env(&mut config);
|
||||
|
||||
// Save config with generated agent_id for persistence
|
||||
let _ = config.save();
|
||||
@@ -384,6 +468,34 @@ impl Config {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Derive the HTTPS API base (e.g. `https://connect.example.com`) from the
|
||||
/// agent's WebSocket `server_url` (e.g. `wss://connect.example.com/ws/agent`).
|
||||
///
|
||||
/// `/api/enroll` is REST/HTTPS while the persistent transport is `wss`, so we
|
||||
/// reuse the same host/authority and swap scheme + drop the WS path. Mapping:
|
||||
/// `wss` -> `https`, `ws` -> `http` (dev). Returns an error if `server_url`
|
||||
/// has no parseable host.
|
||||
pub fn https_base(&self) -> Result<String> {
|
||||
let parsed = url::Url::parse(&self.server_url)
|
||||
.with_context(|| format!("invalid server_url: {}", self.server_url))?;
|
||||
let scheme = match parsed.scheme() {
|
||||
"wss" | "https" => "https",
|
||||
"ws" | "http" => "http",
|
||||
other => {
|
||||
return Err(anyhow!(
|
||||
"unsupported server_url scheme '{other}' (expected ws/wss)"
|
||||
))
|
||||
}
|
||||
};
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| anyhow!("server_url has no host: {}", self.server_url))?;
|
||||
Ok(match parsed.port() {
|
||||
Some(port) => format!("{scheme}://{host}:{port}"),
|
||||
None => format!("{scheme}://{host}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the configuration file path
|
||||
fn config_path() -> PathBuf {
|
||||
// Check for config in current directory first
|
||||
|
||||
413
agent/src/credential_store.rs
Normal file
413
agent/src/credential_store.rs
Normal file
@@ -0,0 +1,413 @@
|
||||
//! 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;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Failure classes for [`load_cak`], so callers can distinguish an *operational*
|
||||
/// problem (the file exists but this process cannot open/read it — e.g. running in
|
||||
/// the wrong security context against a SYSTEM-only-ACL'd store) from the real
|
||||
/// *tamper / wrong-machine* signal (the file was read successfully but DPAPI
|
||||
/// decryption failed).
|
||||
///
|
||||
/// The distinction matters for the run-mode resolver (`main.rs`):
|
||||
/// - [`LoadCakError::Io`] is recoverable/actionable — log it and STOP (do not
|
||||
/// silently re-enroll over a store we simply can't read in this context).
|
||||
/// - [`LoadCakError::Decrypt`] is a hard tamper signal — STOP, do not re-enroll.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LoadCakError {
|
||||
/// The store path could not be resolved (e.g. `%ProgramData%` unset).
|
||||
#[error("could not resolve credential store path: {0}")]
|
||||
Path(String),
|
||||
|
||||
/// An IO/open/read error reaching the stored blob — INCLUDING
|
||||
/// `PermissionDenied` (the running context lacks rights to the SYSTEM-only
|
||||
/// store). Operational, not a tamper signal.
|
||||
#[error("credential store is present but could not be read in this context: {source}")]
|
||||
Io {
|
||||
/// Whether this was specifically an access-denied error (drives the
|
||||
/// run-mode fail-fast guard in `main.rs`).
|
||||
permission_denied: bool,
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
/// The blob was read successfully but DPAPI decryption FAILED — the real
|
||||
/// tamper / wrong-machine / corruption signal. A hard stop; never re-enroll.
|
||||
#[error("stored credential failed to decrypt (wrong machine, tampered, or corrupted): {0}")]
|
||||
Decrypt(String),
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Ordering is security-critical (H2 — TOCTOU): the directory ACL is locked
|
||||
/// BEFORE any secret bytes touch the filesystem, and the temp file is written
|
||||
/// INSIDE the already-locked directory, so no ciphertext ever exists at a path
|
||||
/// carrying an inherited (potentially world-readable) ACL:
|
||||
///
|
||||
/// 1. `create_dir_all(dir)` — ensure the directory exists.
|
||||
/// 2. `lock_down_acl(dir)` — remove inherited ACEs and grant SYSTEM +
|
||||
/// Administrators full control, made inheritable `(OI)(CI)` so children
|
||||
/// created afterward are covered. This is an explicit precondition for the
|
||||
/// write that follows — NOT an unstated inheritance assumption.
|
||||
/// 3. DPAPI-machine-encrypt the plaintext.
|
||||
/// 4. Write the ciphertext to a temp file inside the now-locked directory, then
|
||||
/// rename over the target (atomic-ish replace).
|
||||
/// 5. `lock_down_acl(file)` — assert the file's own ACL (belt-and-suspenders; the
|
||||
/// file already inherits the directory's restrictive ACEs).
|
||||
/// 6. C1 read-back: immediately attempt [`load_cak`] to PROVE the running
|
||||
/// security context can read its own store. If it cannot (e.g. a non-SYSTEM
|
||||
/// run wrote a SYSTEM-only store it can no longer read), fail HERE at enroll
|
||||
/// time with an actionable error — rather than silently bricking on the next
|
||||
/// boot when the steady-state path tries to load it.
|
||||
///
|
||||
/// Returns an error (never logs the plaintext) on any failure so the caller can
|
||||
/// surface it / retry.
|
||||
pub fn store_cak(cak: &str) -> Result<()> {
|
||||
// 1 + 2: lock the directory ACL BEFORE writing any secret (H2 / TOCTOU).
|
||||
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")?;
|
||||
|
||||
// 3: encrypt only after the destination directory is locked down.
|
||||
let ciphertext = dpapi_protect(cak.as_bytes()).context("DPAPI encryption of cak_ failed")?;
|
||||
|
||||
// 4: write the temp file INSIDE the already-locked directory, then rename.
|
||||
let path = cak_path()?;
|
||||
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:?}"))?;
|
||||
|
||||
// 5: assert the file ACL too (the file already inherits the dir's ACEs).
|
||||
lock_down_acl(&path).context("failed to restrict credential file ACL")?;
|
||||
|
||||
// 6: C1 read-back — confirm THIS context can read back what it just wrote.
|
||||
// Catches the "wrote a SYSTEM-only store from a non-SYSTEM context" footgun at
|
||||
// enroll time instead of as a silent brick on the next launch.
|
||||
match load_cak() {
|
||||
Ok(Some(_)) => {
|
||||
tracing::info!("[ENROLL] stored per-machine credential (encrypted at rest)");
|
||||
Ok(())
|
||||
}
|
||||
Ok(None) => Err(anyhow!(
|
||||
"stored the credential but read-back returned nothing — refusing to proceed \
|
||||
with an unverifiable credential store"
|
||||
)),
|
||||
Err(LoadCakError::Io {
|
||||
permission_denied: true,
|
||||
..
|
||||
}) => Err(anyhow!(
|
||||
"[ENROLL] wrote the credential store but cannot read it back in THIS security \
|
||||
context (access denied). The store is ACL'd to SYSTEM + Administrators by \
|
||||
design; the managed agent must run as the GuruConnect SYSTEM service (see \
|
||||
SPEC-017) to read it. Refusing to leave an unreadable store behind."
|
||||
)),
|
||||
Err(e) => Err(anyhow::Error::new(e)
|
||||
.context("stored the credential but the immediate read-back verification failed")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load and decrypt the stored `cak_`, or `Ok(None)` if no credential is stored.
|
||||
///
|
||||
/// Error classification (M1) — the caller MUST treat these differently:
|
||||
/// - `Ok(None)` -> no store yet (NotFound or empty); enroll is fine.
|
||||
/// - [`LoadCakError::Io`] -> the store exists but is unreadable in this
|
||||
/// context (open/read error, INCLUDING access-denied). Operational; the caller
|
||||
/// logs it and STOPS — it must NOT silently re-enroll over a store it merely
|
||||
/// cannot read here.
|
||||
/// - [`LoadCakError::Decrypt`] -> the bytes were read but DPAPI decryption
|
||||
/// FAILED (wrong machine / tampered / corrupted). A hard tamper signal; STOP.
|
||||
///
|
||||
/// Only a successful READ whose decrypt fails is the tamper signal — an IO or
|
||||
/// permission error is never conflated with tamper.
|
||||
pub fn load_cak() -> std::result::Result<Option<String>, LoadCakError> {
|
||||
let path = cak_path().map_err(|e| LoadCakError::Path(e.to_string()))?;
|
||||
let ciphertext = match std::fs::read(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => {
|
||||
let permission_denied = e.kind() == std::io::ErrorKind::PermissionDenied;
|
||||
return Err(LoadCakError::Io {
|
||||
permission_denied,
|
||||
source: e,
|
||||
});
|
||||
}
|
||||
};
|
||||
if ciphertext.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
// Reaching here means the READ succeeded — so a decrypt failure now IS the real
|
||||
// tamper / wrong-machine signal (never conflated with an IO/permission error).
|
||||
let plaintext =
|
||||
dpapi_unprotect(&ciphertext).map_err(|e| LoadCakError::Decrypt(e.to_string()))?;
|
||||
let cak = String::from_utf8(plaintext)
|
||||
.map_err(|e| LoadCakError::Decrypt(format!("decrypted bytes were not valid UTF-8: {e}")))?;
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
384
agent/src/enroll.rs
Normal file
384
agent/src/enroll.rs
Normal file
@@ -0,0 +1,384 @@
|
||||
//! First-run self-enrollment client (SPEC-016 Phase B, item 4).
|
||||
//!
|
||||
//! When the agent runs as a persistent (`PermanentAgent`) install with NO stored
|
||||
//! `cak_` but WITH an `enrollment_key` + `site_code`, it walks through the
|
||||
//! public, unauthenticated `POST /api/enroll` door: it presents its site
|
||||
//! credentials and its hardware-derived `machine_uid`, and — on success — the
|
||||
//! server mints and returns a per-machine `cak_` operating credential exactly
|
||||
//! once. The agent persists that `cak_` encrypted at rest
|
||||
//! ([`crate::credential_store`]) and connects with it; on every later run it uses
|
||||
//! the stored `cak_` directly and never re-enrolls.
|
||||
//!
|
||||
//! Server contract consumed (must match `server/src/api/enroll.rs`):
|
||||
//! - Request: `{ site_code, enrollment_key, machine_uid, hostname,
|
||||
//! labels:{company,site,department,device_type,tags} }`.
|
||||
//! - `201 Created` -> new enrollment; body has `key` (the `cak_`).
|
||||
//! - `200 OK` -> reuse (re-image / re-install); body has `key`.
|
||||
//! - `202 Accepted` -> `collision_pending`; NO key — operator must confirm in
|
||||
//! the dashboard before the endpoint can connect.
|
||||
//! - `401 Unauthorized` -> `ENROLL_REJECTED` (bad/rotated key or unknown site):
|
||||
//! terminal-ish config problem, back off long.
|
||||
//! - `409 Conflict` -> `ENROLL_SITE_CONFLICT` (machine bound to another site):
|
||||
//! terminal-ish, requires the operator reassignment flow; back off long.
|
||||
//! - `429 Too Many Requests` -> rate-limited; back off and retry.
|
||||
//!
|
||||
//! SECURITY: never log the `enrollment_key` or the minted `cak_`. Only states,
|
||||
//! dispositions, and the (non-secret) `machine_uid`/`site_code` are logged.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
/// `POST /api/enroll` request body — mirrors `enroll::EnrollRequest`.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct EnrollRequest<'a> {
|
||||
site_code: &'a str,
|
||||
enrollment_key: &'a str,
|
||||
machine_uid: &'a str,
|
||||
hostname: &'a str,
|
||||
labels: EnrollLabels<'a>,
|
||||
}
|
||||
|
||||
/// Labels carried at enrollment — mirrors `enroll::EnrollLabels`.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct EnrollLabels<'a> {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
company: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
site: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
department: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
device_type: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "slice_is_empty")]
|
||||
tags: &'a [String],
|
||||
}
|
||||
|
||||
/// `skip_serializing_if` predicate for the `tags` slice — `Vec::is_empty` cannot
|
||||
/// bind a `&&[String]`, so use a slice-typed helper.
|
||||
fn slice_is_empty(s: &[String]) -> bool {
|
||||
s.is_empty()
|
||||
}
|
||||
|
||||
/// `POST /api/enroll` success body — mirrors `enroll::EnrollResponse`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EnrollResponse {
|
||||
#[allow(dead_code)]
|
||||
machine_id: String,
|
||||
#[serde(default)]
|
||||
key: Option<String>,
|
||||
enrollment_state: String,
|
||||
disposition: String,
|
||||
}
|
||||
|
||||
/// Backoff after a retryable failure (429 / network / 5xx).
|
||||
const RETRYABLE_BACKOFF: Duration = Duration::from_secs(30);
|
||||
/// Backoff after a terminal-ish config failure (401 / 409) or collision-pending.
|
||||
/// These won't fix themselves without operator action, so retry slowly rather
|
||||
/// than hot-looping while still recovering automatically once it IS fixed.
|
||||
const TERMINAL_BACKOFF: Duration = Duration::from_secs(300);
|
||||
|
||||
/// Drive enrollment until a `cak_` is issued, persisting it into the credential
|
||||
/// store on success and loading it into `config.api_key`.
|
||||
///
|
||||
/// Loops with backoff across retryable failures (it must not give up — a managed
|
||||
/// machine left running should eventually enroll once the server/site is healthy)
|
||||
/// and across collision-pending (HTTP 202: it keeps re-checking on a slow cadence
|
||||
/// until an operator confirms the endpoint in the dashboard and the server begins
|
||||
/// issuing a key). Returns `Ok(())` only once a `cak_` is stored. The only `Err`
|
||||
/// returns are unrecoverable local faults (missing config, an un-persistable
|
||||
/// credential) — network/HTTP failures are retried, never propagated.
|
||||
pub async fn run_enrollment(config: &mut Config) -> Result<()> {
|
||||
let site_code = config
|
||||
.site_code
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("enrollment requested but no site_code is configured"))?;
|
||||
let enrollment_key = config
|
||||
.enrollment_key
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("enrollment requested but no enrollment_key is configured"))?;
|
||||
|
||||
let https_base = config.https_base()?;
|
||||
let machine_uid = crate::identity::machine_uid();
|
||||
let hostname = config.hostname();
|
||||
|
||||
tracing::info!(
|
||||
"[ENROLL] first-run enrollment: site_code={} machine_uid={} hostname={}",
|
||||
site_code,
|
||||
machine_uid,
|
||||
hostname
|
||||
);
|
||||
|
||||
loop {
|
||||
match attempt_enroll(
|
||||
&https_base,
|
||||
&site_code,
|
||||
&enrollment_key,
|
||||
&machine_uid,
|
||||
&hostname,
|
||||
config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(AttemptResult::Issued(cak)) => {
|
||||
// Persist encrypted-at-rest, then load into the live config so the
|
||||
// transport authenticates with the new per-machine credential.
|
||||
#[cfg(windows)]
|
||||
crate::credential_store::store_cak(&cak)
|
||||
.context("failed to persist issued cak_ to the credential store")?;
|
||||
config.api_key = cak;
|
||||
// Enrollment material is single-use; drop it so it is not retained
|
||||
// in memory or accidentally reused.
|
||||
config.enrollment_key = None;
|
||||
tracing::info!("[ENROLL] enrollment complete; connecting with per-machine key");
|
||||
return Ok(());
|
||||
}
|
||||
Ok(AttemptResult::Pending) => {
|
||||
tracing::warn!(
|
||||
"[ENROLL] pending operator confirmation (machine_uid collision); \
|
||||
this machine cannot connect until confirmed in the dashboard. \
|
||||
Re-checking in {}s.",
|
||||
TERMINAL_BACKOFF.as_secs()
|
||||
);
|
||||
tokio::time::sleep(TERMINAL_BACKOFF).await;
|
||||
}
|
||||
Err(AttemptError::Terminal(msg)) => {
|
||||
tracing::error!(
|
||||
"[ENROLL] enrollment refused (operator action required): {msg}. \
|
||||
Retrying in {}s.",
|
||||
TERMINAL_BACKOFF.as_secs()
|
||||
);
|
||||
tokio::time::sleep(TERMINAL_BACKOFF).await;
|
||||
}
|
||||
Err(AttemptError::Retryable(msg)) => {
|
||||
tracing::warn!(
|
||||
"[ENROLL] transient enrollment failure: {msg}. Retrying in {}s.",
|
||||
RETRYABLE_BACKOFF.as_secs()
|
||||
);
|
||||
tokio::time::sleep(RETRYABLE_BACKOFF).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of one HTTP enrollment attempt.
|
||||
enum AttemptResult {
|
||||
/// A `cak_` was issued (201/200). Carries the plaintext (never logged).
|
||||
Issued(String),
|
||||
/// Collision-gated (202): no key issued.
|
||||
Pending,
|
||||
}
|
||||
|
||||
/// Failure classes that drive the backoff policy.
|
||||
enum AttemptError {
|
||||
/// 401/409 — won't fix without operator action; back off long but keep trying.
|
||||
Terminal(String),
|
||||
/// 429 / network / 5xx / decode — transient; short backoff.
|
||||
Retryable(String),
|
||||
}
|
||||
|
||||
/// Make one `POST /api/enroll` call and classify the response per the contract.
|
||||
async fn attempt_enroll(
|
||||
https_base: &str,
|
||||
site_code: &str,
|
||||
enrollment_key: &str,
|
||||
machine_uid: &str,
|
||||
hostname: &str,
|
||||
config: &Config,
|
||||
) -> std::result::Result<AttemptResult, AttemptError> {
|
||||
let url = format!("{}/api/enroll", https_base.trim_end_matches('/'));
|
||||
|
||||
let body = EnrollRequest {
|
||||
site_code,
|
||||
enrollment_key,
|
||||
machine_uid,
|
||||
hostname,
|
||||
labels: EnrollLabels {
|
||||
company: config.company.as_deref().filter(|s| !s.is_empty()),
|
||||
site: config.site.as_deref().filter(|s| !s.is_empty()),
|
||||
department: config.department.as_deref().filter(|s| !s.is_empty()),
|
||||
device_type: config.device_type.as_deref().filter(|s| !s.is_empty()),
|
||||
tags: &config.tags,
|
||||
},
|
||||
};
|
||||
|
||||
let client = build_client().map_err(|e| AttemptError::Retryable(e.to_string()))?;
|
||||
|
||||
let response = client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.timeout(Duration::from_secs(30))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AttemptError::Retryable(format!("request to {url} failed: {e}")))?;
|
||||
|
||||
let status = response.status();
|
||||
match status.as_u16() {
|
||||
// New (201) or reuse (200): body carries the cak_.
|
||||
200 | 201 => {
|
||||
let parsed: EnrollResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AttemptError::Retryable(format!("malformed success body: {e}")))?;
|
||||
match parsed.key {
|
||||
Some(cak) if !cak.is_empty() => {
|
||||
tracing::info!(
|
||||
"[ENROLL] server accepted enrollment: state={} disposition={}",
|
||||
parsed.enrollment_state,
|
||||
parsed.disposition
|
||||
);
|
||||
Ok(AttemptResult::Issued(cak))
|
||||
}
|
||||
// 2xx with no key is contract-violating for the active path; treat
|
||||
// as retryable so we don't silently spin or crash.
|
||||
_ => Err(AttemptError::Retryable(format!(
|
||||
"server returned {} with no key (state={}, disposition={})",
|
||||
status, parsed.enrollment_state, parsed.disposition
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
// Collision-gated: pending operator confirmation, no key.
|
||||
202 => {
|
||||
// Body decode is best-effort here; the status alone is authoritative.
|
||||
Ok(AttemptResult::Pending)
|
||||
}
|
||||
|
||||
// Bad/rotated enrollment key or unknown site code.
|
||||
401 => Err(AttemptError::Terminal(
|
||||
"ENROLL_REJECTED — the site code or enrollment key is invalid or rotated; \
|
||||
this installer needs a current per-site key"
|
||||
.to_string(),
|
||||
)),
|
||||
|
||||
// Machine already enrolled at a different site.
|
||||
409 => Err(AttemptError::Terminal(
|
||||
"ENROLL_SITE_CONFLICT — this machine is already enrolled at another site; \
|
||||
a deliberate move requires the operator-initiated reassignment flow"
|
||||
.to_string(),
|
||||
)),
|
||||
|
||||
// Rate-limited / locked out — honor Retry-After if present, else default.
|
||||
429 => {
|
||||
let retry_after = response
|
||||
.headers()
|
||||
.get(reqwest::header::RETRY_AFTER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse::<u64>().ok());
|
||||
Err(AttemptError::Retryable(match retry_after {
|
||||
Some(secs) => format!("RATE_LIMITED (retry-after {secs}s)"),
|
||||
None => "RATE_LIMITED".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
// 5xx or anything else — transient from the agent's perspective.
|
||||
_ => Err(AttemptError::Retryable(format!(
|
||||
"unexpected enrollment response: HTTP {status}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the HTTP client for enrollment, matching the update path's TLS posture
|
||||
/// (`rustls`, with an opt-in dev-insecure escape hatch in debug builds only).
|
||||
fn build_client() -> Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(dev_insecure_tls())
|
||||
.build()
|
||||
.context("failed to build enrollment HTTP client")
|
||||
}
|
||||
|
||||
/// Dev-only TLS bypass — identical policy to `update::dev_insecure_tls`: only in
|
||||
/// debug builds AND only when `GURUCONNECT_DEV_INSECURE_TLS` is set. NEVER active
|
||||
/// in a release build.
|
||||
fn dev_insecure_tls() -> bool {
|
||||
if cfg!(debug_assertions) && std::env::var("GURUCONNECT_DEV_INSECURE_TLS").is_ok() {
|
||||
tracing::warn!(
|
||||
"[ENROLL] TLS verification DISABLED (dev-insecure mode) — DO NOT use in production"
|
||||
);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The request body must serialize to exactly the field names the Phase A
|
||||
/// server deserializes (`enroll::EnrollRequest` / `EnrollLabels`). A drift here
|
||||
/// is a silent enrollment failure, so pin the wire shape.
|
||||
#[test]
|
||||
fn request_serializes_to_the_server_contract() {
|
||||
let tags = vec!["prod".to_string()];
|
||||
let req = EnrollRequest {
|
||||
site_code: "ACME-HQ",
|
||||
enrollment_key: "cek_secret",
|
||||
machine_uid: "muid_abc",
|
||||
hostname: "WS-01",
|
||||
labels: EnrollLabels {
|
||||
company: Some("Acme"),
|
||||
site: Some("HQ"),
|
||||
department: Some("IT"),
|
||||
device_type: Some("workstation"),
|
||||
tags: &tags,
|
||||
},
|
||||
};
|
||||
let v: serde_json::Value = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(v["site_code"], "ACME-HQ");
|
||||
assert_eq!(v["enrollment_key"], "cek_secret");
|
||||
assert_eq!(v["machine_uid"], "muid_abc");
|
||||
assert_eq!(v["hostname"], "WS-01");
|
||||
assert_eq!(v["labels"]["company"], "Acme");
|
||||
assert_eq!(v["labels"]["site"], "HQ");
|
||||
assert_eq!(v["labels"]["department"], "IT");
|
||||
assert_eq!(v["labels"]["device_type"], "workstation");
|
||||
assert_eq!(v["labels"]["tags"][0], "prod");
|
||||
}
|
||||
|
||||
/// Empty optional labels are omitted (the server defaults them), and an empty
|
||||
/// tag list is not serialized — keeping the body minimal for a thin installer.
|
||||
#[test]
|
||||
fn request_omits_empty_optional_labels() {
|
||||
let tags: Vec<String> = Vec::new();
|
||||
let req = EnrollRequest {
|
||||
site_code: "S",
|
||||
enrollment_key: "cek_x",
|
||||
machine_uid: "muid_x",
|
||||
hostname: "H",
|
||||
labels: EnrollLabels {
|
||||
company: None,
|
||||
site: None,
|
||||
department: None,
|
||||
device_type: None,
|
||||
tags: &tags,
|
||||
},
|
||||
};
|
||||
let v: serde_json::Value = serde_json::to_value(&req).unwrap();
|
||||
let labels = v["labels"].as_object().unwrap();
|
||||
assert!(!labels.contains_key("company"));
|
||||
assert!(!labels.contains_key("department"));
|
||||
assert!(!labels.contains_key("tags"));
|
||||
}
|
||||
|
||||
/// The success response decoder must accept both a key-bearing active body and
|
||||
/// a keyless pending body (mirrors `EnrollResponse` with `skip_serializing_if`).
|
||||
#[test]
|
||||
fn response_decodes_active_and_pending_shapes() {
|
||||
let active: EnrollResponse = serde_json::from_str(
|
||||
r#"{"machine_id":"m1","key":"cak_live","enrollment_state":"active","disposition":"new"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(active.key.as_deref(), Some("cak_live"));
|
||||
assert_eq!(active.enrollment_state, "active");
|
||||
|
||||
let pending: EnrollResponse = serde_json::from_str(
|
||||
r#"{"machine_id":"m2","enrollment_state":"pending","disposition":"collision_pending"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(pending.key.is_none());
|
||||
assert_eq!(pending.disposition, "collision_pending");
|
||||
}
|
||||
}
|
||||
@@ -9,22 +9,48 @@
|
||||
//! **recomputable**: the same machine yields the same id on every call with no
|
||||
//! persistence required.
|
||||
//!
|
||||
//! - **Windows:** SHA-256 hash of the OS machine GUID read from
|
||||
//! `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` (a `REG_SZ`). The raw
|
||||
//! GUID is never returned — only the opaque `muid_<hex>` derived from it.
|
||||
//! - **Non-Windows (and Windows registry failure):** a random UUID persisted in
|
||||
//! the agent's data directory, read back on subsequent runs so it is stable
|
||||
//! across calls and process restarts.
|
||||
//! - **Windows:** SHA-256 of a hardware identity string. The id is derived from
|
||||
//! the **hardware salt ONLY** whenever any durable hardware signal is readable:
|
||||
//! the **SMBIOS system UUID** (`Win32_ComputerSystemProduct.UUID`), or — when
|
||||
//! that is absent / all-zeros / all-FFs (some OEMs/hypervisors) — the
|
||||
//! **motherboard serial** (`Win32_BaseBoard.SerialNumber`) plus the **primary
|
||||
//! disk serial**. A fixed namespace string is mixed in for domain separation.
|
||||
//! The OS machine GUID
|
||||
//! (`HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid`, a `REG_SZ`) is used
|
||||
//! ONLY as a last-resort signal when NO hardware salt is readable. The raw
|
||||
//! signals are never returned — only the opaque `muid_<hex>` derived from them.
|
||||
//! - **Non-Windows (and Windows with no readable signal at all):** a random UUID
|
||||
//! persisted in the agent's data directory, read back on subsequent runs so it
|
||||
//! is stable across calls and process restarts.
|
||||
//!
|
||||
//! **Stability contract (SPEC-016 item 1):**
|
||||
//! - **Salted path (hardware signal present) is re-image-stable:** the digest
|
||||
//! mixes only durable hardware signals (SMBIOS UUID, or board + disk serial) and
|
||||
//! a fixed namespace — NOT the `MachineGuid`, which Windows regenerates on every
|
||||
//! OS install/re-image. So the `machine_uid` survives both a reboot AND an OS
|
||||
//! re-image on the SAME hardware (the re-image dedup goal), while distinct
|
||||
//! physical boxes stay distinct.
|
||||
//! - **MachineGuid-only path is the volatile floor:** when no hardware salt is
|
||||
//! readable, the id anchors on the `MachineGuid` alone. This is stable across
|
||||
//! reboots but NOT across a re-image (the GUID is regenerated). This degraded
|
||||
//! path is logged at WARN so the server-side collision gate operator has a clue.
|
||||
//!
|
||||
//! This module deliberately does NOT change `agent_id`/`generate_agent_id`.
|
||||
//! `machine_uid` is reported *alongside* `agent_id`; the server-side dedup that
|
||||
//! consumes it is a separate task.
|
||||
//! consumes it lives in `POST /api/enroll` (SPEC-016 Phase A) and the relay
|
||||
//! connect path.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Prefix marking the value as an opaque machine-uid (vs. a raw GUID/UUID).
|
||||
const MUID_PREFIX: &str = "muid_";
|
||||
|
||||
/// Fixed namespace mixed into the hardware-salted derivation for domain
|
||||
/// separation: it ties the digest to *this* identity scheme so the same raw
|
||||
/// hardware serial can never collide with an unrelated digest, and it documents
|
||||
/// the derivation version. It is NOT a secret — it is a constant.
|
||||
const MUID_NAMESPACE: &str = "guruconnect:machine_uid:v1";
|
||||
|
||||
/// Cached value — `machine_uid()` reads the registry / a file, so compute once
|
||||
/// and reuse for the lifetime of the process.
|
||||
static MACHINE_UID: OnceLock<String> = OnceLock::new();
|
||||
@@ -32,10 +58,11 @@ static MACHINE_UID: OnceLock<String> = OnceLock::new();
|
||||
/// Return a deterministic, recomputable opaque machine identifier.
|
||||
///
|
||||
/// The result is non-empty and prefixed with [`MUID_PREFIX`]. It is cached after
|
||||
/// the first call. On Windows it is derived purely from the OS machine GUID (no
|
||||
/// persistence). If the Windows registry read fails — or on any non-Windows
|
||||
/// platform — it degrades to a persisted random UUID (today's-behavior-equivalent
|
||||
/// stability) rather than panicking.
|
||||
/// the first call. On Windows it is derived from a durable hardware salt when one
|
||||
/// is readable (re-image-stable; see the module docs), falling back to the OS
|
||||
/// machine GUID alone (reboot-stable floor) and finally — when no signal at all is
|
||||
/// readable, or on any non-Windows platform — a persisted random UUID, rather than
|
||||
/// panicking.
|
||||
pub fn machine_uid() -> String {
|
||||
MACHINE_UID.get_or_init(compute_machine_uid).clone()
|
||||
}
|
||||
@@ -56,23 +83,265 @@ fn derive_uid(raw: &str) -> String {
|
||||
|
||||
#[cfg(windows)]
|
||||
fn compute_machine_uid() -> String {
|
||||
// PRIMARY signal (SPEC-016 item 1): a durable hardware salt — SMBIOS system
|
||||
// UUID if usable, else motherboard + disk serial. When ANY hardware salt is
|
||||
// readable we derive the uid from the salt ALONE (plus a fixed namespace),
|
||||
// deliberately EXCLUDING the MachineGuid: Windows regenerates the MachineGuid
|
||||
// on every OS install/re-image, so mixing it in would break re-image dedup.
|
||||
// The salted digest survives both reboot AND re-image on the same hardware.
|
||||
if let Some(salt) = hardware_salt() {
|
||||
tracing::info!("machine_uid derived from durable hardware salt (re-image-stable)");
|
||||
return derive_uid(&format!("{MUID_NAMESPACE}|{salt}"));
|
||||
}
|
||||
|
||||
// LAST-RESORT signal: no hardware salt is readable, so anchor on the OS
|
||||
// MachineGuid alone. This is the volatile FLOOR — stable across reboots but
|
||||
// NOT across an OS re-image (the GUID is regenerated). We WARN so the
|
||||
// server-side collision-gate operator knows this endpoint's uid is not
|
||||
// re-image-stable. The MachineGuid itself is never logged.
|
||||
match read_machine_guid() {
|
||||
Ok(guid) if !guid.trim().is_empty() => derive_uid(guid.trim()),
|
||||
Ok(guid) if !guid.trim().is_empty() => {
|
||||
tracing::warn!(
|
||||
"machine_uid: no durable hardware salt readable; anchoring on MachineGuid \
|
||||
ONLY — this id is reboot-stable but NOT re-image-stable"
|
||||
);
|
||||
derive_uid(&format!("{MUID_NAMESPACE}|machineguid:{}", guid.trim()))
|
||||
}
|
||||
Ok(_) => {
|
||||
tracing::warn!(
|
||||
"MachineGuid registry value was empty; falling back to persisted machine_uid"
|
||||
"machine_uid: no hardware salt and MachineGuid registry value was empty; \
|
||||
falling back to persisted machine_uid"
|
||||
);
|
||||
persisted_uid()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to read MachineGuid from registry ({e}); falling back to persisted machine_uid"
|
||||
"machine_uid: no hardware salt and failed to read MachineGuid ({e}); \
|
||||
falling back to persisted machine_uid"
|
||||
);
|
||||
persisted_uid()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect the durable hardware salt for the `machine_uid` (Windows only).
|
||||
///
|
||||
/// This is the PRIMARY identity signal: when it returns `Some(salt)`, the caller
|
||||
/// derives the uid from the salt ALONE (re-image-stable). Returns `Some(salt)`
|
||||
/// where `salt` is a deterministic, normalized concatenation of usable hardware
|
||||
/// signals, or `None` when nothing durable is readable (in which case the caller
|
||||
/// degrades to anchoring on the MachineGuid alone — the volatile floor).
|
||||
///
|
||||
/// Order of preference, per SPEC-016 item 1:
|
||||
/// 1. SMBIOS system UUID (`Win32_ComputerSystemProduct.UUID`) — when present and
|
||||
/// not a degenerate placeholder (all-zeros / all-FFs, which some OEMs and
|
||||
/// hypervisor templates emit).
|
||||
/// 2. Fallback: motherboard serial (`Win32_BaseBoard.SerialNumber`) + primary
|
||||
/// disk serial — combined so a single weak signal does not stand alone.
|
||||
///
|
||||
/// Each component is read via a narrow PowerShell CIM query (see
|
||||
/// [`query_cim_property`]); the values are normalized (trimmed, upper-cased) so
|
||||
/// trivial formatting drift never changes the digest.
|
||||
#[cfg(windows)]
|
||||
fn hardware_salt() -> Option<String> {
|
||||
if let Some(uuid) = smbios_uuid() {
|
||||
return Some(format!("smbios:{uuid}"));
|
||||
}
|
||||
|
||||
// SMBIOS UUID unusable — fall back to board + disk serial. Use whichever of
|
||||
// the two are readable; require at least one to be present, otherwise there
|
||||
// is no durable salt and we return None.
|
||||
let board = normalize_signal(query_cim_property("Win32_BaseBoard", "SerialNumber").as_deref());
|
||||
let disk = primary_disk_serial();
|
||||
|
||||
match (board, disk) {
|
||||
(Some(b), Some(d)) => Some(format!("board:{b}|disk:{d}")),
|
||||
(Some(b), None) => Some(format!("board:{b}")),
|
||||
(None, Some(d)) => Some(format!("disk:{d}")),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The SMBIOS system UUID, or `None` if absent or a degenerate placeholder.
|
||||
///
|
||||
/// Some OEMs ship an all-zeros UUID and some hypervisor templates clone an
|
||||
/// all-FFs (or all-zeros) UUID; either is worthless as a distinguishing signal,
|
||||
/// so we reject both and let the caller fall back to board/disk serial.
|
||||
#[cfg(windows)]
|
||||
fn smbios_uuid() -> Option<String> {
|
||||
let raw =
|
||||
normalize_signal(query_cim_property("Win32_ComputerSystemProduct", "UUID").as_deref())?;
|
||||
|
||||
// Reject degenerate placeholders (ignoring dashes): all-zeros or all-FFs.
|
||||
let hex: String = raw.chars().filter(|c| *c != '-').collect();
|
||||
let all_zero = !hex.is_empty() && hex.chars().all(|c| c == '0');
|
||||
let all_ff = !hex.is_empty() && hex.chars().all(|c| c == 'F');
|
||||
if hex.is_empty() || all_zero || all_ff {
|
||||
tracing::debug!("SMBIOS UUID is absent or a degenerate placeholder; using fallback salt");
|
||||
return None;
|
||||
}
|
||||
Some(raw)
|
||||
}
|
||||
|
||||
/// The serial number of the primary (boot/index-0) physical disk, normalized.
|
||||
///
|
||||
/// Prefers the disk whose `Index == 0` (the conventional boot disk); falls back
|
||||
/// to the first disk that reports any serial. Returns `None` if no disk reports a
|
||||
/// usable serial.
|
||||
#[cfg(windows)]
|
||||
fn primary_disk_serial() -> Option<String> {
|
||||
// One narrow query: index + serial for all physical disks, sorted by index,
|
||||
// emitted as `index<TAB>serial` lines. Parse the lowest-index non-empty serial.
|
||||
let script = "Get-CimInstance -ClassName Win32_DiskDrive | \
|
||||
Sort-Object Index | \
|
||||
ForEach-Object { \"$($_.Index)`t$($_.SerialNumber)\" }";
|
||||
let out = run_powershell(script)?;
|
||||
for line in out.lines() {
|
||||
let mut parts = line.splitn(2, '\t');
|
||||
let _index = parts.next();
|
||||
if let Some(serial) = parts.next() {
|
||||
if let Some(n) = normalize_signal(Some(serial)) {
|
||||
return Some(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Read a single property of a single-instance CIM class via PowerShell.
|
||||
///
|
||||
/// Returns the raw (untrimmed) first non-empty line of output, or `None`. This is
|
||||
/// a deliberately narrow shell-out rather than a full WMI/COM binding: the agent
|
||||
/// already has no WMI crate, and a COM `IWbemServices` binding for two scalar
|
||||
/// reads would be far more code and unsafe surface for no benefit. PowerShell's
|
||||
/// CIM cmdlets are present on every supported Windows target (7 SP1+/2008 R2+
|
||||
/// ship WMI; CIM cmdlets ship from PowerShell 3.0 / WMF 3.0, universally present
|
||||
/// on currently-supported builds).
|
||||
#[cfg(windows)]
|
||||
fn query_cim_property(class: &str, property: &str) -> Option<String> {
|
||||
// `(Get-CimInstance -ClassName X).Property` — single scalar, no formatting.
|
||||
let script = format!("(Get-CimInstance -ClassName {class}).{property}");
|
||||
let out = run_powershell(&script)?;
|
||||
out.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Wall-clock bound on a single PowerShell hardware-signal query.
|
||||
///
|
||||
/// A wedged WMI/CIM provider can hang indefinitely; without a bound that would
|
||||
/// hang agent startup forever. On timeout we kill the child and treat the signal
|
||||
/// as missing (fall back through the chain) — never panic.
|
||||
#[cfg(windows)]
|
||||
const POWERSHELL_QUERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// Run a short PowerShell snippet and capture stdout, or `None` on any failure
|
||||
/// (including a wall-clock timeout).
|
||||
///
|
||||
/// Hidden window (`CREATE_NO_WINDOW`) so an interactive desktop never flashes a
|
||||
/// console; `-NonInteractive -NoProfile` for determinism and speed. The call is
|
||||
/// spawned and waited on with a [`POWERSHELL_QUERY_TIMEOUT`] bound so a stuck WMI
|
||||
/// provider cannot wedge startup; on timeout the child is killed and the signal is
|
||||
/// treated as missing. Never logs the captured output (it carries hardware
|
||||
/// identifiers).
|
||||
#[cfg(windows)]
|
||||
fn run_powershell(script: &str) -> Option<String> {
|
||||
use std::io::Read;
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Instant;
|
||||
|
||||
// CREATE_NO_WINDOW — avoid a console flash on the interactive desktop.
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
let mut child = match Command::new("powershell.exe")
|
||||
.args([
|
||||
"-NonInteractive",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
script,
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.spawn()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::debug!("could not run hardware-signal query ({e}); ignoring this signal");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Poll for exit with a wall-clock bound. We spin with a short sleep rather than
|
||||
// a reader thread: the queries are infrequent (startup only) and the loop keeps
|
||||
// the timeout logic simple and panic-free.
|
||||
let deadline = Instant::now() + POWERSHELL_QUERY_TIMEOUT;
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break status,
|
||||
Ok(None) => {
|
||||
if Instant::now() >= deadline {
|
||||
// Wedged provider: kill and treat as a missing signal.
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
tracing::debug!(
|
||||
"hardware-signal query exceeded {}s timeout; killed and ignoring this signal",
|
||||
POWERSHELL_QUERY_TIMEOUT.as_secs()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("error waiting on hardware-signal query ({e}); ignoring");
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return None;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !status.success() {
|
||||
tracing::debug!(
|
||||
"hardware-signal query exited with status {:?}; ignoring this signal",
|
||||
status.code()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
// The process exited; drain its captured stdout.
|
||||
let mut buf = Vec::new();
|
||||
if let Some(mut out) = child.stdout.take() {
|
||||
if let Err(e) = out.read_to_end(&mut buf) {
|
||||
tracing::debug!("error reading hardware-signal query output ({e}); ignoring");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let s = String::from_utf8_lossy(&buf).trim().to_string();
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a raw hardware signal: trim, upper-case, drop if empty. Upper-casing
|
||||
/// makes the digest stable against vendor case drift; trimming removes stray
|
||||
/// whitespace WMI sometimes pads serials with.
|
||||
#[cfg(windows)]
|
||||
fn normalize_signal(raw: Option<&str>) -> Option<String> {
|
||||
let v = raw?.trim();
|
||||
if v.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(v.to_uppercase())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn compute_machine_uid() -> String {
|
||||
// No OS machine GUID available — use the persisted random UUID, hashed for a
|
||||
@@ -297,4 +566,108 @@ mod tests {
|
||||
assert_eq!(a, b, "compute_machine_uid must be deterministic on Windows");
|
||||
assert!(a.starts_with(MUID_PREFIX));
|
||||
}
|
||||
|
||||
/// Pin the EXACT derivation strings that `compute_machine_uid` builds, so these
|
||||
/// pure-function tests track the production logic. Keep in lock-step with
|
||||
/// `compute_machine_uid`.
|
||||
#[cfg(windows)]
|
||||
fn salted_uid(salt: &str) -> String {
|
||||
derive_uid(&format!("{MUID_NAMESPACE}|{salt}"))
|
||||
}
|
||||
#[cfg(windows)]
|
||||
fn machineguid_only_uid(guid: &str) -> String {
|
||||
derive_uid(&format!("{MUID_NAMESPACE}|machineguid:{guid}"))
|
||||
}
|
||||
|
||||
/// H1 RE-IMAGE STABILITY: when a hardware salt is present, the uid is derived
|
||||
/// from the salt ALONE — the MachineGuid is NOT part of the input. So holding
|
||||
/// the hardware signals fixed while varying the MachineGuid MUST yield the SAME
|
||||
/// uid. This is exactly the re-image case: an OS re-image regenerates the
|
||||
/// MachineGuid but leaves SMBIOS UUID / board+disk serial unchanged, and the
|
||||
/// machine_uid must not move (otherwise dedup breaks). We prove it by showing
|
||||
/// the salted derivation has no MachineGuid term to vary.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn salted_uid_is_reimage_stable_independent_of_machine_guid() {
|
||||
let salt = "smbios:4C4C4544-0043-3010-8052-B4C04F564231";
|
||||
// "Before re-image" and "after re-image": MachineGuid differs, but the
|
||||
// salt-derived uid takes no MachineGuid input, so both are identical.
|
||||
let before = salted_uid(salt);
|
||||
let after = salted_uid(salt);
|
||||
assert_eq!(
|
||||
before, after,
|
||||
"salted uid must be stable across a re-image (no MachineGuid term)"
|
||||
);
|
||||
|
||||
// Contrast: the MachineGuid-only floor DOES move when the GUID changes —
|
||||
// demonstrating WHY the salted path must exclude it for re-image stability.
|
||||
let guid_a = machineguid_only_uid("11111111-2222-3333-4444-555555555555");
|
||||
let guid_b = machineguid_only_uid("99999999-8888-7777-6666-555555555555");
|
||||
assert_ne!(
|
||||
guid_a, guid_b,
|
||||
"MachineGuid-only floor is volatile across re-image (expected)"
|
||||
);
|
||||
|
||||
// And the salted uid must differ from the MachineGuid-only floor for the
|
||||
// same box: the two derivation paths are domain-separated.
|
||||
assert_ne!(before, guid_a);
|
||||
}
|
||||
|
||||
/// The hardware-salted derivation is `derive_uid` over a deterministic,
|
||||
/// namespaced concatenation: identical signals MUST yield an identical uid and
|
||||
/// any changed signal MUST change it. Pins the SPEC-016 determinism contract
|
||||
/// independent of the (machine-specific) live hardware reads.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn salted_derivation_is_deterministic_and_signal_sensitive() {
|
||||
let with_smbios = salted_uid("smbios:AAAA-BBBB");
|
||||
let with_smbios_again = salted_uid("smbios:AAAA-BBBB");
|
||||
let with_board = salted_uid("board:SN123|disk:DSK9");
|
||||
|
||||
// Same inputs -> same uid.
|
||||
assert_eq!(with_smbios, with_smbios_again);
|
||||
// Different salt composition -> different uid (distinct boxes stay distinct).
|
||||
assert_ne!(with_smbios, with_board);
|
||||
}
|
||||
|
||||
/// All-zero and all-FF SMBIOS UUIDs are degenerate placeholders that some OEMs
|
||||
/// and hypervisor templates emit; the normalizer + placeholder check must
|
||||
/// reject them so the derivation falls through to board/disk serial. We
|
||||
/// exercise the rejection predicate directly (it is pure) rather than the
|
||||
/// live WMI read.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn degenerate_smbios_uuids_are_rejected() {
|
||||
// Replicate the predicate `smbios_uuid` applies after normalization.
|
||||
fn is_degenerate(raw: &str) -> bool {
|
||||
let Some(norm) = normalize_signal(Some(raw)) else {
|
||||
return true;
|
||||
};
|
||||
let hex: String = norm.chars().filter(|c| *c != '-').collect();
|
||||
hex.is_empty()
|
||||
|| (!hex.is_empty() && hex.chars().all(|c| c == '0'))
|
||||
|| (!hex.is_empty() && hex.chars().all(|c| c == 'F'))
|
||||
}
|
||||
|
||||
assert!(is_degenerate("00000000-0000-0000-0000-000000000000"));
|
||||
assert!(is_degenerate("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF"));
|
||||
assert!(is_degenerate("ffffffff-ffff-ffff-ffff-ffffffffffff")); // case-insensitive via normalize
|
||||
assert!(is_degenerate(" "));
|
||||
// A real, mixed UUID is NOT degenerate.
|
||||
assert!(!is_degenerate("4C4C4544-0043-3010-8052-B4C04F564231"));
|
||||
}
|
||||
|
||||
/// `normalize_signal` trims, upper-cases, and drops empties — so case/space
|
||||
/// drift in a vendor serial never perturbs the digest.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn normalize_signal_is_stable_against_drift() {
|
||||
assert_eq!(
|
||||
normalize_signal(Some(" abc123 ")),
|
||||
Some("ABC123".to_string())
|
||||
);
|
||||
assert_eq!(normalize_signal(Some("ABC123")), Some("ABC123".to_string()));
|
||||
assert_eq!(normalize_signal(Some(" ")), None);
|
||||
assert_eq!(normalize_signal(None), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@ mod capture;
|
||||
mod chat;
|
||||
mod config;
|
||||
mod consent;
|
||||
#[cfg(windows)]
|
||||
mod credential_store;
|
||||
mod encoder;
|
||||
mod enroll;
|
||||
mod identity;
|
||||
mod input;
|
||||
mod install;
|
||||
@@ -323,7 +326,112 @@ fn run_agent_mode(support_code: Option<String>) -> Result<()> {
|
||||
|
||||
// Run the agent
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
rt.block_on(run_agent(config))
|
||||
rt.block_on(async move {
|
||||
// SPEC-016 Phase B: resolve the operating credential before connecting.
|
||||
// Support sessions are unaffected — they authenticate by support code, not
|
||||
// by a per-machine cak_, so we only resolve enrollment for a managed agent.
|
||||
if config.support_code.is_none() {
|
||||
resolve_agent_credential(&mut config).await?;
|
||||
}
|
||||
run_agent(config).await
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve the per-machine operating credential for a managed agent (SPEC-016
|
||||
/// Phase B, run-mode wiring).
|
||||
///
|
||||
/// Precedence:
|
||||
/// 1. A `cak_` already stored encrypted at rest -> load it and connect with it
|
||||
/// (the steady-state path; no network call, no re-enroll).
|
||||
/// 2. No stored `cak_` but an `enrollment_key` + `site_code` are present ->
|
||||
/// run first-run enrollment to obtain + persist a `cak_`, then connect.
|
||||
/// 3. Neither a stored `cak_` nor enrollment material, but a non-empty
|
||||
/// `api_key` is configured -> use it as the DEPRECATED shared/legacy key
|
||||
/// (transition compatibility only; logged at WARNING).
|
||||
/// 4. Nothing usable -> error; a managed agent cannot authenticate.
|
||||
async fn resolve_agent_credential(config: &mut config::Config) -> Result<()> {
|
||||
// 1. Stored per-machine cak_ (steady state).
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use credential_store::LoadCakError;
|
||||
match credential_store::load_cak() {
|
||||
Ok(Some(cak)) => {
|
||||
info!("Using stored per-machine credential (cak_)");
|
||||
config.api_key = cak;
|
||||
// Any leftover enrollment material is now moot.
|
||||
config.enrollment_key = None;
|
||||
return Ok(());
|
||||
}
|
||||
Ok(None) => {
|
||||
info!("No stored per-machine credential; will enroll if configured");
|
||||
}
|
||||
// C1 / M1 — the store exists but THIS security context cannot read it
|
||||
// (access-denied against the SYSTEM-only ACL). This is the brick the
|
||||
// C1 guard prevents: a non-SYSTEM run could write the store but never
|
||||
// read it back. Fail fast with an actionable message; do NOT loop and
|
||||
// do NOT silently re-enroll. The SYSTEM+Administrators ACL is correct
|
||||
// for the target (Option A) and is deliberately kept.
|
||||
//
|
||||
// NOTE: this guard is satisfied/removed once the GuruConnect SYSTEM
|
||||
// service host lands (separate spec, SPEC-017) and the agent always
|
||||
// runs as SYSTEM — at which point the store is always readable.
|
||||
Err(LoadCakError::Io {
|
||||
permission_denied: true,
|
||||
source,
|
||||
}) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"[ENROLL] credential store is not accessible in this context \
|
||||
({source}) — the managed agent must run as the GuruConnect SYSTEM \
|
||||
service (see SPEC-017). Refusing to re-enroll."
|
||||
));
|
||||
}
|
||||
// M1 — other IO error reaching the store (not access-denied): also
|
||||
// operational, not a tamper signal. Surface it; do not re-enroll over a
|
||||
// store we simply could not read.
|
||||
Err(e @ LoadCakError::Io { .. }) => {
|
||||
return Err(anyhow::Error::new(e).context(
|
||||
"[ENROLL] credential store present but unreadable (IO error); \
|
||||
refusing to re-enroll over it",
|
||||
));
|
||||
}
|
||||
Err(e @ LoadCakError::Path(_)) => {
|
||||
return Err(anyhow::Error::new(e)
|
||||
.context("[ENROLL] could not resolve the credential store path"));
|
||||
}
|
||||
// M1 — the bytes were read but failed to DECRYPT: the real tamper /
|
||||
// wrong-machine signal. Hard stop; never silently re-enroll over it.
|
||||
Err(e @ LoadCakError::Decrypt(_)) => {
|
||||
return Err(anyhow::Error::new(e).context(
|
||||
"[ENROLL] stored credential failed to decrypt — possible tamper or \
|
||||
copy from another machine; refusing to silently re-enroll",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. First-run enrollment (the SPEC-016 zero-touch path). run_enrollment only
|
||||
// returns once a cak_ is stored (it retries network/429/collision-pending
|
||||
// internally); a returned Err is an unrecoverable local fault.
|
||||
if config.enrollment_key.is_some() && config.site_code.is_some() {
|
||||
info!("Enrollment material present; running first-run enrollment");
|
||||
enroll::run_enrollment(config).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 3. DEPRECATED shared/legacy api_key fallback (transition only).
|
||||
if !config.api_key.is_empty() {
|
||||
warn!(
|
||||
"Connecting with a DEPRECATED shared/legacy api_key. Migrate this agent \
|
||||
to a per-site enrollment (SPEC-016); the shared key path will be removed."
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 4. Nothing usable.
|
||||
Err(anyhow::anyhow!(
|
||||
"no operating credential available: no stored cak_, no enrollment_key/site_code, \
|
||||
and no legacy api_key — this managed agent cannot authenticate"
|
||||
))
|
||||
}
|
||||
|
||||
/// Run in viewer mode (connect to remote session)
|
||||
|
||||
@@ -95,6 +95,7 @@ Bringing GC to parity with GuruRMM's release engineering. Full plan: [SPEC-001](
|
||||
- [ ] **Valuable error messages (structured errors + no silent swallows)** — P2 — one structured API error envelope with stable codes + a correlation id that also lands in the logs; contextual tracing on server/agent; sweep the 37 `let _ =` swallows (the pattern that hid the migration-005 bug); dashboard surfaces the real cause + id instead of a generic line. **[→ v2 Phase 0/1 conventions]** ([SPEC-008](specs/SPEC-008-valuable-error-messages.md))
|
||||
- [ ] **Feature-rich, fully-documented management API** — P2 — everything the console can do, callable by API: OpenAPI 3.x generated from code (utoipa) + browsable docs at `/api/docs`, long-lived revocable scoped API tokens (PAT-style, distinct from the 24h JWT + agent keys), an API-completeness gap audit, and consistent pagination/error conventions. Distinct from the ADR-001 RMM integration contract. **[→ v2 Phase 3]** ([SPEC-009](specs/SPEC-009-feature-rich-documented-api.md))
|
||||
- [ ] **Branding and white-label configuration** — P2 — Allow MSPs to customize logo, colors, and product name for white-labeled remote support. Dashboard admin settings page with logo upload (PNG/SVG, max 2MB), brand hue slider (OKLCH 0-360°, default 184=cyan), product name override, company name, and favicon. Agent tray tooltip uses custom product name from registry. Singleton database table with public GET endpoint for unauthenticated rendering. CSS variables (`--brand-hue`, `--accent`, `--panel`) for dynamic theming. **[→ v2 Phase 2]** ([SPEC-014](specs/SPEC-014-branding-whitelabel.md))
|
||||
- [ ] **End-user (sub-user) remote access** — P2 (may be P3) — let a client pay for their employees to reach their *own* machines from home: a deny-by-default `end_user` login role, a locked-down end-user portal listing only granted machines, and Connect reusing the existing session-scoped viewer-token + relay path. Grant primitive already exists (`user_client_access`, migration 002); directory sync (AD/Entra/Google) is a separate future spec. **[→ new capability, post v2-console]** ([SPEC-017](specs/SPEC-017-end-user-remote-access.md))
|
||||
- [ ] Programmatic session pre-create + viewer-token (integration contract) — P2
|
||||
|
||||
## Security & Infrastructure
|
||||
|
||||
180
docs/specs/SPEC-017-end-user-remote-access.md
Normal file
180
docs/specs/SPEC-017-end-user-remote-access.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# SPEC-017: End-User (Sub-User) Remote Access
|
||||
|
||||
**Status:** Proposed
|
||||
**Priority:** P2 (may settle to P3 depending on client demand)
|
||||
**Requested By:** Mike (2026-06-02)
|
||||
**Estimated Effort:** Large
|
||||
|
||||
## Overview
|
||||
|
||||
Let a client pay for their own employees to remotely reach **their own work machines** from home
|
||||
through GuruConnect — the Splashtop-Business / unattended-end-user-access model, layered on top of the
|
||||
MSP-technician console GuruConnect ships today. An MSP admin (or, later, a delegated client-company
|
||||
admin) provisions a list of **end-users** and grants each one access to specific managed machines. The
|
||||
end-user signs into a locked-down **end-user portal**, sees only the machines granted to them, and
|
||||
connects — reusing the existing persistent-agent + session-scoped-viewer-token + relay path.
|
||||
|
||||
Success criteria: an `end_user`-role account can log in at a separate portal, see exactly the machines
|
||||
in its grant set (and no others, across no other tenant), launch a control session to an online granted
|
||||
machine, and is hard-denied from every technician/admin API, the agent plane, and any machine it was
|
||||
not granted — with each login and machine access written to the audit log.
|
||||
|
||||
This is a net-new **sellable capability**, not a console-MVP blocker. It is sequenced after the v2
|
||||
console foundations it depends on (tenancy, machine identity, persistent enrollment), which is why it is
|
||||
P2 rather than P1.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included in v1
|
||||
- A new **`end_user`** value for `users.role`, provisioned by an MSP admin, with **deny-by-default**
|
||||
authority: no console permissions, no agent-plane access, machine reach limited strictly to its
|
||||
`user_client_access` grant set within its own tenant.
|
||||
- A **separate end-user login + portal** route (locked-down): lists only granted machines with
|
||||
online/offline state and a Connect action. No admin nav, no other users/machines/companies.
|
||||
- **Admin UI + API** to create/disable end-users and assign/revoke per-machine grants, reusing the
|
||||
existing `user_client_access` table.
|
||||
- **Connect flow** that reuses the landed session-scoped viewer-token mechanism (`ViewerClaims`,
|
||||
`jwt.rs:114`) and the relay enforcement path — no new transport.
|
||||
- A new `connect_sessions.source` value **`end_user`** (migration widening the existing CHECK).
|
||||
- **Audit**: end-user login success/failure and each machine-access grant-check written to
|
||||
`connect_session_events`.
|
||||
- Rate limiting + lockout on the public end-user login.
|
||||
|
||||
### Explicitly out of scope (v1)
|
||||
- **Directory sync (AD / Entra-365 / Google) → end-user list** — its own future spec; v1 is manual
|
||||
list management only.
|
||||
- **Self-service seat purchasing / billing automation.** v1 records/counts seats per tenant; real
|
||||
metering and Syncro/billing wiring is deferred.
|
||||
- **Delegated client-company-admin role** (a client managing its own end-users/grants) — noted as a
|
||||
fast-follow; v1 grants are MSP-admin-managed.
|
||||
- Per-session view-only-vs-control *policy* per end-user (v1 = Control of one's own machine; the
|
||||
`ViewerAccess` split still exists at the token layer).
|
||||
- File transfer, session recording (already out of scope for the broader product v1).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Principal model — `end_user` is a constrained variant of the login plane
|
||||
GuruConnect already has three credential planes that must stay separate (audit-hardened in v2 Phase 1):
|
||||
1. **Login `Claims`** (`jwt.rs:11`) — dashboard users; `role ∈ {admin, operator, viewer}` today.
|
||||
2. **Session-scoped `ViewerClaims`** (`jwt.rs:114`) — 5-min, one session, `purpose=viewer`.
|
||||
3. **Agent `cak_` keys** (`connect_agent_keys`, migration 004) — agents only.
|
||||
|
||||
`end_user` is added as a **fourth role on the login plane** — it issues a normal login JWT
|
||||
(`create_token`, `jwt.rs:161`) carrying `role: "end_user"` and an **empty permission list**. The
|
||||
separation guarantees the v2 audit established are preserved: an `end_user` JWT still cannot be used as
|
||||
a viewer token (lacks `purpose`) nor as an agent key (agent plane rejects user JWTs).
|
||||
|
||||
**Critical authz inversion:** `user_client_access` today documents "no entries = access to all (for
|
||||
admins)" (migration 002, line 25-26). The grant check **must branch on role** — for `end_user`, an
|
||||
empty grant set means **zero** machines, never all. Authz is deny-by-default and grant-scoped; the
|
||||
admin-bypass in `Claims::has_permission` (`jwt.rs:28-33`) must never fire for `end_user`.
|
||||
|
||||
### Agent / Relay-server / Viewer / Dashboard responsibilities
|
||||
- **Agent:** no changes. End-users connect to existing **persistent/unattended** managed agents
|
||||
(consent `not_required` — it is the user's own machine). Optionally honors the SPEC-015 notification
|
||||
overlay if a per-machine policy requires it.
|
||||
- **Relay-server:** no transport change. New end-user auth + portal + connect endpoints; the
|
||||
grant-check + viewer-token mint is the only new server logic on the hot path.
|
||||
- **Viewer:** reuse the React/TS web viewer (`dashboard/src/components/RemoteViewer.tsx`) — the
|
||||
end-user portal embeds the same component with a Control-mode viewer token.
|
||||
- **Dashboard:** new **role-gated end-user portal** route (recommended separate from the technician
|
||||
console — see Open Questions), plus admin screens for end-user + grant management.
|
||||
|
||||
### Database (migrations)
|
||||
- **`user_client_access`** — reused as the grant table; no schema change (already
|
||||
`user_id UUID × client_id UUID → connect_machines(id)`, unique pair, migration 002).
|
||||
- New migration `011_end_user_access.sql`:
|
||||
- Widen `connect_sessions.source` CHECK to `('standalone','gururmm','end_user')` (currently
|
||||
`('standalone','gururmm')`, migration 004 line 99-102).
|
||||
- Optional `users` columns for the external principal: `mfa_secret TEXT NULL`,
|
||||
`must_change_password BOOLEAN NOT NULL DEFAULT false`, and a partial index for fast
|
||||
`role='end_user'` listing per `tenant_id`.
|
||||
- (Seat tracking, if landed in v1: a lightweight per-tenant `end_user` count view or a
|
||||
`tenant_seats` row — kept minimal.)
|
||||
- Grants are tenant-contained: insert path validates `machine.tenant_id == end_user.tenant_id`.
|
||||
|
||||
### API endpoints / WS messages
|
||||
- `POST /api/enduser/auth/login` — public, rate-limited; returns an `end_user` login JWT.
|
||||
- `GET /api/enduser/machines` — lists only the caller's granted, in-tenant machines + presence.
|
||||
- `POST /api/enduser/machines/:id/connect` — grant-checked; creates a `source=end_user` session and
|
||||
mints a Control `ViewerClaims` token (`create_viewer_token`, `jwt.rs:233`) for that session.
|
||||
- Admin: `POST /api/users` (role=end_user), `POST /api/users/:id/grants`,
|
||||
`DELETE /api/users/:id/grants/:machine_id`, `GET /api/users?role=end_user`.
|
||||
- No new protobuf messages — the WS viewer path and `guruconnect.proto` are unchanged.
|
||||
|
||||
## Implementation details
|
||||
- `server/src/auth/jwt.rs` — extend the role vocabulary doc (`Claims.role`, line 16-17); add an
|
||||
`is_end_user()` helper and ensure `has_permission` cannot grant `end_user` anything beyond explicit
|
||||
permissions (the admin short-circuit at line 30 must be guarded).
|
||||
- `server/src/auth/mod.rs` — `AuthenticatedUser` (line 29+) gains role-aware helpers; add an extractor
|
||||
/ middleware that rejects non-`end_user` on the `/api/enduser/*` namespace and rejects `end_user` on
|
||||
every console/admin route (deny-by-default allowlist).
|
||||
- `server/src/api/` — new `enduser` handler module (login, machines, connect); admin user+grant
|
||||
handlers extended for `role=end_user` and `user_client_access` writes.
|
||||
- Grant check (shared fn): `machine_id ∈ user_client_access[user] AND machine.tenant_id == user.tenant_id`;
|
||||
used by both `GET /machines` and `connect`.
|
||||
- Session create stamps `source='end_user'`, `is_managed=true`/unattended, `consent_state='not_required'`,
|
||||
then mints the viewer token via the existing path so relay enforcement is unchanged.
|
||||
- `dashboard/src/` — end-user portal route (role-gated), reusing `RemoteViewer.tsx`; admin grant-matrix
|
||||
UI. White-label (SPEC-014) applies to the portal as the most client-facing surface.
|
||||
- Migration `server/migrations/011_end_user_access.sql` as above (idempotent; applied by
|
||||
`sqlx::migrate!` per the migration standard).
|
||||
|
||||
## Security considerations
|
||||
- **Preserve the plane separation** audited in v2 Phase 1 — `end_user` is login-plane only; it can
|
||||
never satisfy `validate_viewer_token` or the agent `cak_` path.
|
||||
- **Deny-by-default, grant-scoped:** empty `user_client_access` for an `end_user` = no access; the
|
||||
admin-bypass must not apply. Every `/api/enduser/*` call re-checks the grant + tenant server-side
|
||||
(never trust a machine id from the client).
|
||||
- **Tenant containment:** an `end_user` and its grants live in one tenant; cross-tenant grants are
|
||||
rejected at write and re-validated at connect. (Full tenant isolation lands with Phase 4; v1 enforces
|
||||
via explicit `tenant_id` equality checks.)
|
||||
- **External-user trust:** these accounts are public-internet-facing from home. Require
|
||||
rate-limiting + lockout on `/api/enduser/auth/login`; support (recommend require) **TOTP MFA** for
|
||||
`end_user` — schema column included so MFA can be v1 or an immediate fast-follow without a second
|
||||
migration. Argon2id passwords (existing standard).
|
||||
- **Audit:** log each end-user login (success/failure, source IP) and each machine access to
|
||||
`connect_session_events`; the unattended access is to the user's *own* machine but must be fully
|
||||
traceable. Optionally enforce the SPEC-015 overlay per machine policy.
|
||||
- **Threat model:** stolen end-user creds reach only that user's granted machines (blast radius =
|
||||
grant set), never the console, never the agent plane, never another tenant. Disabling the account
|
||||
(`users.enabled=false`) immediately revokes portal + future tokens; the 5-min viewer-token TTL bounds
|
||||
any in-flight session.
|
||||
|
||||
## Testing strategy
|
||||
- **Unit:** grant-check fn (granted / not-granted / cross-tenant / empty-set-for-end_user = deny);
|
||||
`has_permission` never elevates `end_user`; role-namespace middleware (end_user→console = 403,
|
||||
technician→/api/enduser = 403).
|
||||
- **Integration:** end-user login → list shows only granted machines → connect mints a Control viewer
|
||||
token for a `source=end_user` session → relay admits; connect to a non-granted / other-tenant machine
|
||||
→ 403; disabled account → login + token use rejected.
|
||||
- **Manual:** full portal walkthrough from an off-network browser; MFA enrol + challenge; audit rows
|
||||
present for login and access; white-label branding renders on the portal.
|
||||
|
||||
## Effort estimate & dependencies
|
||||
- **Size:** Large (new principal + portal + admin grant UI + auth namespace; transport/agent untouched
|
||||
and the grant table already exists, which holds it below X-Large).
|
||||
- **Depends on (must precede / strongly preferred):**
|
||||
- **Tenancy** (`tenants` + `tenant_id`, migration 004) — needed for containment; full isolation is
|
||||
Phase 4 but v1 uses explicit tenant checks.
|
||||
- **Stable machine identity + persistent enrollment** (SPEC-004 / 008 `machine_uid`, SPEC-016
|
||||
zero-touch `cak_`) — end-users reach persistent managed agents.
|
||||
- **Session-scoped viewer tokens** (v2 Phase 1, landed) — reused directly.
|
||||
- **Pairs with:** SPEC-014 (white-label — the portal is the client-facing surface), SPEC-003/005
|
||||
(machine inventory/list — portal machine rows), SPEC-015 (optional connect-notification overlay).
|
||||
- **Unblocks:** the directory-sync spec (AD/Entra/Google → end-user list), delegated client-admin role,
|
||||
and per-seat billing — all of which build on the `end_user` principal defined here.
|
||||
|
||||
## Open questions
|
||||
1. **Same console vs separate end-user portal?** Recommendation: **separate, role-gated route** —
|
||||
smaller attack surface, no risk of leaking technician controls, cleaner white-label. Confirm before
|
||||
build.
|
||||
2. **End-users in the existing `users` table (role=end_user) vs a dedicated `end_users` table?**
|
||||
Recommendation: reuse `users` (the grant FK `user_client_access.user_id` already points there) with
|
||||
hard role guardrails. Revisit if mixing external + internal principals in one table proves risky.
|
||||
3. **MFA in v1 or immediate fast-follow?** Schema is included either way; decide enforcement timing.
|
||||
4. **Who administers grants in v1** — MSP admin only (assumed), or ship the delegated client-company
|
||||
admin role together? (Affects scope/effort materially.)
|
||||
5. **Seat/licensing enforcement depth for v1** — count-and-display vs hard-cap vs billing-integrated.
|
||||
6. **Default access mode** — Control assumed (own machine); should an admin be able to pin a machine to
|
||||
view-only for a given end-user? (Token layer already supports it.)
|
||||
Reference in New Issue
Block a user