diff --git a/Cargo.lock b/Cargo.lock index de9f796..283964a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2027,7 +2027,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.38.0" +version = "0.38.1" dependencies = [ "axum", "axum-server", diff --git a/Cargo.toml b/Cargo.toml index 177e98c..5900abc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ edition = "2021" # the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.38.0" +version = "0.38.1" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index 1415d3c..11faa52 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -114,3 +114,13 @@ CLI path (not via a native package) needs the `sc config`/`sc qc` dance. ("link.exe could not be run" / `DirectoryNotFoundException` on a `.tlog`) because the deep `modules/.worktrees/dig-node-624/target/...` path trips MSBuild/cmake MAX_PATH — set a short `CARGO_TARGET_DIR` (e.g. `C:\dnt624`) to build. +- **Privileged-owner check walks the WHOLE path (#712):** `crate::security::dir_is_privileged` (the + shared #565/#661/#46 gate) verifies EVERY ancestor component, not just the leaf, and rejects any + symlink/junction/reparse component — a privileged leaf under a user-writable or symlinked ancestor + is still swappable (intermediate rename/replace obeys the PARENT's perms; a reparse redirects the + whole path). Windows gotcha: `C:\Program Files` is owned by `NT SERVICE\TrustedInstaller` + (fixed SID `S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464`, byte-identical on + every host), NOT SYSTEM/Administrators — so the ancestor walk MUST accept that SID or it + false-rejects the canonical `%ProgramFiles%\DIG\bin` install root. Reparse detection uses the + no-follow `symlink_metadata` + `FILE_ATTRIBUTE_REPARSE_POINT` (catches junctions, not just + symlinks). Mirrors dig-dns's `ensure_prefix_root_owned_not_writable` (#701). diff --git a/SPEC.md b/SPEC.md index 64cc843..f2097e5 100644 --- a/SPEC.md +++ b/SPEC.md @@ -311,13 +311,26 @@ node runs ONE catch-up pass (which fully reconciles the leaf) rather than bursti per missed tick. **TLS-root owner gate (#661, defence-in-depth).** Before reading ANY TLS material — the leaf to -serve, and `ca.key` on the renewal path — the node verifies the TLS root directory is owned by a -privileged principal (Windows: the well-known `SYSTEM`/`Administrators` SID, by exact SID equality; -unix: `root` and not group/world-writable) and **fails CLOSED to plaintext** otherwise. A -user-writable TLS root could hold an attacker-swapped `ca.key` that this privileged service would -otherwise read and sign with; refusing it removes that vector. The owner SID is read directly through -the Win32 security API (launching no process — the same spawn-free check the self-heal LPE gate uses, -§ the shared owner check), so the guard never itself executes an attacker-planted binary. +serve, and `ca.key` on the renewal path — the node verifies the TLS root directory is privileged-owned +(§ the shared whole-path owner check) and **fails CLOSED to plaintext** otherwise. A user-writable TLS +root could hold an attacker-swapped `ca.key` that this privileged service would otherwise read and sign +with; refusing it removes that vector. The owner SID is read directly through the Win32 security API +(launching no process — the same spawn-free check the self-heal LPE gate uses), so the guard never +itself executes an attacker-planted binary. + +**Shared whole-path owner check (#565/#661/#46, #712).** The three gates above defer to ONE shared +helper that classifies a directory as privileged-owned. It verifies the ENTIRE path, not just the leaf: +EVERY existing ancestor component (the directory, its parent, … up to the filesystem root) MUST be +privileged-owned AND MUST NOT be a symlink/junction/reparse point. A privileged-owned leaf under a +user-writable or symlinked ANCESTOR is still tamperable — an intermediate rename/replace is governed by +the parent's permissions, and a reparse point anywhere redirects the whole path — so a single weak +component fails the check. Per component: **unix** — owned by `root`/uid 0, no group/other write bit, +judged via `symlink_metadata` (lstat) so a symlink is rejected on its own identity; **Windows** — owner +SID equal (exact equality) to the well-known LocalSystem `S-1-5-18`, BUILTIN\Administrators +`S-1-5-32-544`, or `NT SERVICE\TrustedInstaller` (the fixed service SID that owns `C:\Program Files` +and its protected subtree — required so the canonical `%ProgramFiles%` install root is not +false-rejected), and the component carries no `FILE_ATTRIBUTE_REPARSE_POINT`. Fails CLOSED on any +indeterminate or missing component. **The CA trust anchor is NEVER auto-rotated by the node.** An approaching CA expiry is only REPORTED (`ca_renewal_due`); anchor rotation is an explicit, installer-coordinated `dig-cert rotate_ca` (it @@ -1464,11 +1477,11 @@ SCM, always LocalSystem; a root systemd/launchd daemon) records the currently-ru user-writable directory, a non-privileged local user could replace it and gain persistent SYSTEM/root code execution on the next service start — a privilege-escalation vector. So before registering a system-level service, `install` MUST verify the program's directory is -**privileged-owned** (Unix: `root`/uid 0 and no group/other write bit; Windows: an owner SID equal -to the well-known LocalSystem `S-1-5-18` or BUILTIN\Administrators `S-1-5-32-544`) and **refuse with -`PERMISSION_DENIED`** otherwise, before any side effect (no state-dir harden, no service create). -This is the SAME spawn-free owner gate the self-heal spawn root (§7 #565) and the TLS material root -(§4.1a #661) use — one shared check, fail-closed on an indeterminate owner. A **user-level** install +**privileged-owned across its whole path** (§ the shared whole-path owner check — every ancestor +component privileged-owned, non-reparse) and **refuse with `PERMISSION_DENIED`** otherwise, before any +side effect (no state-dir harden, no service create). This is the SAME spawn-free owner gate the +self-heal spawn root (§7 #565) and the TLS material root (§4.1a #661) use — one shared check, +fail-closed on an indeterminate owner. A **user-level** install (the Linux/macOS default) runs as the very user who owns the binary, crosses no privilege boundary, and is always allowed. The canonical install path (native OS package, §9.7) places the binary in a protected admin-owned location (`%ProgramFiles%\DIG Network\dig-node\`, `/usr/…`), so it satisfies diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index 3740249..1aefc41 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -154,6 +154,7 @@ windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", + "Win32_Storage_FileSystem", ] } # Test-only WS client for the `GET /ws/status` integration tests (#239): connects diff --git a/crates/dig-node-service/src/security.rs b/crates/dig-node-service/src/security.rs index adc7c49..c4d6ce0 100644 --- a/crates/dig-node-service/src/security.rs +++ b/crates/dig-node-service/src/security.rs @@ -10,33 +10,87 @@ //! //! The check fails CLOSED: any inability to determine the owner is treated as untrusted. //! -//! - **Unix:** the directory must be owned by `root` (uid 0) AND carry no group/other write bit -//! (`mode & 0o022 == 0`). A non-root owner or a group/world-writable bit fails it. -//! - **Windows:** the directory's OWNER SID must be the well-known LocalSystem (`S-1-5-18`) or -//! BUILTIN\Administrators (`S-1-5-32-544`); a user-owned dir keeps `WRITE_DAC` and so is treated -//! as user-writable. The owner SID is read directly through the Win32 security API — launching NO -//! process — and compared for EQUALITY against those well-known SIDs. +//! The WHOLE path must be trustworthy, not just the leaf (#712): a privileged-owned leaf sitting +//! under a user-writable — or symlinked/junctioned — ANCESTOR can still be swapped out from beneath +//! (rename/replace of an intermediate directory is governed by the PARENT's permissions, and a +//! reparse point anywhere in the chain redirects the whole path). So [`dir_is_privileged`] walks +//! EVERY existing ancestor component and holds each to the same bar, and rejects any component that +//! is a symlink/junction/reparse point — matching the higher bar dig-dns already ships (#701). +//! +//! - **Unix:** every path component must be owned by `root` (uid 0), carry no group/other write bit +//! (`mode & 0o022 == 0`), and NOT be a symlink (judged by `symlink_metadata`/lstat, so a symlink +//! is classified on its own identity, never its target). +//! - **Windows:** every path component's OWNER SID must be the well-known LocalSystem (`S-1-5-18`), +//! BUILTIN\Administrators (`S-1-5-32-544`), or `NT SERVICE\TrustedInstaller` (the fixed service +//! SID that owns `C:\Program Files` and its protected subtree) — a user-owned dir keeps +//! `WRITE_DAC` and so is treated as user-writable — AND the component must NOT be a reparse point +//! (`FILE_ATTRIBUTE_REPARSE_POINT`). The owner SID is read directly through the Win32 security API +//! — launching NO process — and compared for EQUALITY against those well-known SIDs. use std::path::Path; -/// Whether `dir` is a privileged directory — owned by a privileged principal and not writable by an -/// ordinary user. The single trust gate shared by the self-heal spawn root (#565) and the TLS -/// material root (#661). Fails CLOSED on any inability to determine the owner. +/// Whether `dir` is a privileged directory — owned by a privileged principal and safe from tampering +/// by an ordinary user AT EVERY LEVEL. The single trust gate shared by the self-heal spawn root +/// (#565), the TLS material root (#661), and the system-service install target (#46). +/// +/// Walks the ENTIRE ancestor chain of `dir` (`dir`, its parent, … up to the filesystem root): every +/// component must be privileged-owned, not group/world-writable, and not a symlink/junction/reparse +/// point ([`component_is_privileged`]). A single weak component anywhere defeats the leaf's own +/// ownership, so one failure fails the whole check. Fails CLOSED: an indeterminate or missing +/// component is untrusted. `dir.ancestors()` always yields at least `dir`, so an empty chain can +/// never vacuously pass. pub fn dir_is_privileged(dir: &Path) -> bool { - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - match std::fs::metadata(dir) { - Ok(md) => md.uid() == 0 && (md.mode() & 0o022) == 0, - Err(_) => false, - } + dir.ancestors().all(component_is_privileged) +} + +/// Whether a SINGLE existing path `component` clears the trust bar: privileged-owned, not +/// user-writable, and not a symlink/junction/reparse point. Fails CLOSED on any metadata error +/// (missing, access denied) — an indeterminate component is untrusted. Used for every level by +/// [`dir_is_privileged`]. +#[cfg(unix)] +fn component_is_privileged(component: &Path) -> bool { + use std::os::unix::fs::MetadataExt; + // `symlink_metadata` (lstat) does NOT follow a symlink — a symlink component is judged on its + // own identity so it can be rejected outright, never on the (possibly attacker-chosen) target. + match std::fs::symlink_metadata(component) { + Ok(md) => component_privileged(md.uid(), md.mode(), md.file_type().is_symlink()), + Err(_) => false, } - #[cfg(windows)] - { - windows_owner_is_privileged(dir) +} + +/// PURE unix per-component policy: a component is privileged iff it is NOT a symlink, is owned by +/// root (uid 0), and grants no group/other write bit (`mode & 0o022 == 0`). Kept pure so every arm +/// is unit-tested without a real root-owned tree. +#[cfg(unix)] +fn component_privileged(uid: u32, mode: u32, is_symlink: bool) -> bool { + !is_symlink && uid == 0 && (mode & 0o022) == 0 +} + +/// Whether a SINGLE existing path `component` clears the trust bar on Windows: not a reparse point, +/// and owned by a privileged principal. Fails CLOSED on any metadata/owner-read error. Used for +/// every level by [`dir_is_privileged`]. +#[cfg(windows)] +fn component_is_privileged(component: &Path) -> bool { + use std::os::windows::fs::MetadataExt; + // Reject a reparse point (symlink/junction/mount point) BEFORE trusting the owner: a reparse + // anywhere in the chain redirects the whole path, and the owner read below would FOLLOW it. + // `symlink_metadata` (a no-follow lstat) classifies the component on its own identity. + match std::fs::symlink_metadata(component) { + Ok(md) if attrs_are_reparse(md.file_attributes()) => false, + Ok(_) => windows_owner_is_privileged(component), + Err(_) => false, } } +/// PURE: do NTFS file attributes carry the reparse-point flag (`FILE_ATTRIBUTE_REPARSE_POINT`) — +/// i.e. is this component a symlink, junction, or mount point? Kept pure so the bit test is +/// unit-tested without a real reparse point (which requires privilege to create). +#[cfg(windows)] +fn attrs_are_reparse(attrs: u32) -> bool { + use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT; + attrs & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + /// Windows: read `dir`'s OWNER SID directly via the Win32 security API and accept only the /// well-known LocalSystem or BUILTIN\Administrators owner. Fails CLOSED on any read error. The /// acceptance policy is kept PURE in [`owner_sid_is_privileged`] so it is unit-testable without a @@ -60,14 +114,26 @@ pub(crate) fn windows_owner_is_privileged(dir: &Path) -> bool { } } +/// The fixed `NT SERVICE\TrustedInstaller` service SID. It owns `C:\Program Files` and its protected +/// subtree by default, so the #712 ancestor walk MUST accept it or it would false-reject the +/// canonical `%ProgramFiles%\DIG\bin` install root (whose `C:\Program Files` ancestor is +/// TrustedInstaller-owned). It is a well-known constant — a service SID is derived deterministically +/// from the service name, so it is byte-identical on every Windows host — and is privileged: an +/// ordinary user cannot write a TrustedInstaller-owned directory without first taking ownership, +/// which itself requires privilege. +#[cfg(windows)] +const SID_TRUSTED_INSTALLER: &str = + "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"; + /// PURE: does an owner SID *string* (e.g. `S-1-5-18`) name a privileged owner? Compared for exact -/// EQUALITY against the well-known LocalSystem (`S-1-5-18`) and BUILTIN\Administrators -/// (`S-1-5-32-544`) SIDs — never a localized display name and never a `\Administrators` name -/// suffix, so a domain group merely *named* "Administrators" (a different SID) does NOT pass. +/// EQUALITY against the well-known LocalSystem (`S-1-5-18`), BUILTIN\Administrators (`S-1-5-32-544`), +/// and `NT SERVICE\TrustedInstaller` ([`SID_TRUSTED_INSTALLER`]) SIDs — never a localized display +/// name and never a `\Administrators` name suffix, so a domain group merely *named* "Administrators" +/// (a different SID) does NOT pass. #[cfg(windows)] pub(crate) fn owner_sid_is_privileged(sid: &str) -> bool { let sid = sid.trim().to_ascii_uppercase(); - sid == "S-1-5-18" || sid == "S-1-5-32-544" + sid == "S-1-5-18" || sid == "S-1-5-32-544" || sid == SID_TRUSTED_INSTALLER } /// Read `dir`'s owner SID as its canonical string form (`S-1-5-…`) via the Win32 security API, @@ -183,6 +249,61 @@ mod tests { ); } + /// The PURE unix per-component policy (#712): a component passes ONLY when it is root-owned, not + /// group/world-writable, AND not a symlink. This is the per-level bar the ancestor walk applies. + #[cfg(unix)] + #[test] + fn unix_component_policy_requires_root_owned_non_writable_non_symlink() { + // Root-owned, non-symlink, root-only-writable — the only shape that passes. + assert!(component_privileged(0, 0o755, false)); + assert!(component_privileged(0, 0o700, false)); + assert!(component_privileged(0, 0o555, false)); + + // A symlink is rejected even when root-owned + not writable (#712 reparse rejection): a + // symlinked component could redirect the privileged path onto an attacker-chosen target. + assert!(!component_privileged(0, 0o755, true)); + + // Ownership / writability failures. + assert!(!component_privileged(1000, 0o755, false)); // not root-owned + assert!(!component_privileged(0, 0o775, false)); // group-writable + assert!(!component_privileged(0, 0o757, false)); // world-writable + } + + /// Regression (#712): a symlink ANYWHERE in the chain is refused — here the leaf itself is a + /// symlink. `dir_is_privileged` walks via `symlink_metadata`, so it judges the symlink on its + /// own identity (never following it to a possibly-privileged target) and fails closed. + #[cfg(unix)] + #[test] + fn a_symlink_component_is_never_privileged() { + let base = tempfile::tempdir().unwrap(); + let target = base.path().join("real"); + std::fs::create_dir(&target).unwrap(); + let link = base.path().join("link"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + assert!( + !dir_is_privileged(&link), + "a symlink path component must never be trusted, regardless of its target" + ); + } + + /// Regression (#712): the walk reaches every ANCESTOR, so a user-writable parent condemns the + /// whole path. A child dir under a world-writable parent is refused even though the child itself + /// is not writable — proving `dir_is_privileged` no longer trusts a leaf under a weak ancestor. + #[cfg(unix)] + #[test] + fn a_child_under_a_user_writable_parent_is_not_privileged() { + use std::os::unix::fs::PermissionsExt; + let parent = tempfile::tempdir().unwrap(); + std::fs::set_permissions(parent.path(), std::fs::Permissions::from_mode(0o777)).unwrap(); + let child = parent.path().join("child"); + std::fs::create_dir(&child).unwrap(); + std::fs::set_permissions(&child, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!( + !dir_is_privileged(&child), + "a non-writable child under a user-writable parent must not be trusted (ancestor walk)" + ); + } + /// The owner-SID policy accepts ONLY the well-known SYSTEM / BUILTIN\Administrators SIDs, by /// exact SID EQUALITY. A localized/display NAME is not a SID and never passes, and — the #565 /// second-order fix — a lookalike group literally *named* "Administrators" (any other SID) is @@ -211,6 +332,39 @@ mod tests { assert!(!owner_sid_is_privileged("MACHINE\\alice")); } + /// #712: `NT SERVICE\TrustedInstaller` — the fixed service SID that owns `C:\Program Files` and + /// its protected subtree — MUST be accepted, or the ancestor walk would false-reject the + /// canonical `%ProgramFiles%\DIG\bin` install root (whose `C:\Program Files` ancestor is + /// TrustedInstaller-owned). The SID is a byte-identical constant on every Windows host. + #[cfg(windows)] + #[test] + fn windows_owner_policy_accepts_the_trusted_installer_sid() { + use super::{owner_sid_is_privileged, SID_TRUSTED_INSTALLER}; + assert!(owner_sid_is_privileged(SID_TRUSTED_INSTALLER)); + // Case/whitespace-insensitive, like the other well-known SIDs. + assert!(owner_sid_is_privileged(&format!( + " {} ", + SID_TRUSTED_INSTALLER.to_ascii_lowercase() + ))); + } + + /// #712: the PURE NTFS-attribute reparse test flags a symlink/junction/mount point (any component + /// carrying `FILE_ATTRIBUTE_REPARSE_POINT`) and passes an ordinary directory. A reparse component + /// anywhere in the chain redirects the whole path, so the walk rejects it before trusting the owner. + #[cfg(windows)] + #[test] + fn windows_reparse_attribute_is_detected() { + use super::attrs_are_reparse; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10; + assert!(attrs_are_reparse(FILE_ATTRIBUTE_REPARSE_POINT)); + assert!(attrs_are_reparse( + FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT + )); + // An ordinary directory is not a reparse point. + assert!(!attrs_are_reparse(FILE_ATTRIBUTE_DIRECTORY)); + } + /// Regression (#565 LPE, second-order): the owner probe must NEVER execute a binary planted in /// the very directory it is classifying. A low-privilege user owns a tempdir (the user-writable /// root the gate must refuse) and plants a `powershell.exe` there; the SID-based probe must