rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

commit 5799dab326238d92d0f4e63856fee3fd3d423cab
parent 52cd39192a9ae55c8aee09c50359e5fb5db34105
Author: triesap <tyson@radroots.org>
Date:   Tue, 25 Aug 2026 09:09:56 +0000

security: qualify the standalone systemd unit

- Replace invalid config-directory aliases with canonical systemd directives.
- Freeze fail-closed restart, shutdown, directory, and hardening posture.
- Add exact unit contracts, negative tests, and Linux analyzer gates.
- Defer compatibility-sensitive filters to the real-binary integration wave.

Diffstat:
MAGENTS.md | 10++++++++++
MREADME | 18++++++++++++++----
Acontracts/services_hardening/systemd_qualification.v1.json | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackaging/systemd/rhi@.service | 24+++++++++++++++++++++---
Ascripts/verify-systemd.sh | 45+++++++++++++++++++++++++++++++++++++++++++++
Mtests/services_hardening_native_release.rs | 3++-
Atests/services_hardening_systemd.rs | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 357 insertions(+), 8 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -513,6 +513,15 @@ parent-owned human docs, credentials, Nix or OCI inputs/outputs, private harnesses, signatures, tags, publication, deployment, or generated artifacts in the source tree. +- RCLD-RSHR-150 Step 227 owns the fixed standalone systemd unit and + `systemd_qualification.v1.json`. Keep the canonical service-host directory + directives, stable exit-code restart split, bounded stop, empty capability + sets, no environment-carried credentials, systemd 252 minimum, and maximum + offline exposure 3.0 exact. The Linux verifier must fail closed when + `systemd-analyze` is absent. Do not enable compatibility-sensitive + `MemoryDenyWriteExecute` or syscall filters until the Step 229 integration + wave proves them against the real binary; do not install, enable, start, or + deploy a production service here. ## 10. Canonical verification @@ -529,6 +538,7 @@ cargo extbuild run -- cargo clippy --workspace --all-targets --locked -- -D warn cargo extbuild run -- env RUSTDOCFLAGS=-Dwarnings cargo doc --workspace --no-deps --locked cargo extbuild run -- ./scripts/verify-boundaries.sh cargo extbuild run -- ./scripts/verify-supply-chain.sh +cargo extbuild run -- ./scripts/verify-systemd.sh cargo extbuild run -- ./scripts/release-acceptance.sh ``` diff --git a/README b/README @@ -825,10 +825,20 @@ SBOM component references are domain-separated hashes of framed Cargo name/version/source/checksum identity, so they neither disclose a checkout path nor vary when the same exact source is built from another directory. -The checked-in systemd instance unit is package material only. Its presence -does not claim that the governed daemon is installed, enabled, started, or -production-activated. Native artifact qualification remains Step 216 -ownership, and promotion remains Step 217 ownership. +The standalone Linux systemd boundary is frozen by +`contracts/services_hardening/systemd_qualification.v1.json` and checked by +`scripts/verify-systemd.sh`. The instance unit uses the canonical service-host +paths, fixed unprivileged account, restrictive directory modes and umask, +fixed restart delay, bounded stop behavior, empty capabilities, no environment-based secret +input, and the reviewed filesystem, kernel, namespace, process, and address- +family protections. The Linux-only verifier requires systemd 252 or newer, +runs syntax verification, and rejects an offline security exposure above 3.0. +Type `simple` remains deliberate: readiness is the cached CLI/admin contract, +not `sd_notify`. Compatibility-sensitive `MemoryDenyWriteExecute` and syscall +filters remain deferred to the Step 229 integration wave rather than being +enabled without real-binary evidence. This qualification does not install, +enable, start, stop, or deploy a production service. Native artifact qualification +remains Step 216 ownership, and promotion remains Step 217 ownership. Validate the standalone crate through extbuild: diff --git a/contracts/services_hardening/systemd_qualification.v1.json b/contracts/services_hardening/systemd_qualification.v1.json @@ -0,0 +1,106 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "schema": "radroots.rhi.systemd-qualification", + "schema_version": 1, + "service": "rhi", + "unit_path": "packaging/systemd/rhi@.service", + "verification_script": "scripts/verify-systemd.sh", + "unit_sha256": "12e6659042b540d0c202a041ef97714089a0c919a5ef6f6c22c04db744daffdc", + "canonical_lines": [ + "[Unit]", + "Description=Radroots RHI evidence service instance %i", + "After=network-online.target", + "Wants=network-online.target", + "StartLimitIntervalSec=0", + "[Service]", + "Type=simple", + "User=rhi", + "Group=rhi", + "UMask=0077", + "ExecStart=/usr/bin/rhi --profile service-host --instance %i run", + "Restart=on-failure", + "RestartSec=5s", + "RestartPreventExitStatus=2 4 5 6", + "TimeoutStopSec=310s", + "KillSignal=SIGTERM", + "FinalKillSignal=SIGKILL", + "ConfigurationDirectory=radroots/services/rhi/%i", + "ConfigurationDirectoryMode=0700", + "StateDirectory=radroots/services/rhi/%i", + "StateDirectoryMode=0700", + "CacheDirectory=radroots/services/rhi/%i", + "CacheDirectoryMode=0700", + "LogsDirectory=radroots/services/rhi/%i", + "LogsDirectoryMode=0700", + "RuntimeDirectory=radroots/services/rhi/%i", + "RuntimeDirectoryMode=0700", + "RuntimeDirectoryPreserve=restart", + "NoNewPrivileges=yes", + "PrivateTmp=yes", + "PrivateDevices=yes", + "ProtectHome=yes", + "ProtectSystem=strict", + "ProtectClock=yes", + "ProtectControlGroups=yes", + "ProtectHostname=yes", + "ProtectKernelLogs=yes", + "ProtectKernelModules=yes", + "ProtectKernelTunables=yes", + "ProtectProc=invisible", + "ProcSubset=pid", + "RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6", + "RestrictNamespaces=yes", + "RestrictRealtime=yes", + "RestrictSUIDSGID=yes", + "LockPersonality=yes", + "CapabilityBoundingSet=", + "AmbientCapabilities=", + "KeyringMode=private", + "RemoveIPC=yes", + "SystemCallArchitectures=native", + "[Install]", + "WantedBy=multi-user.target" + ], + "runtime_posture": { + "type": "simple", + "readiness": "cached_cli_no_sd_notify", + "restartable_exit_codes": [1, 3], + "nonrestartable_exit_codes": [2, 4, 5, 6], + "restart_delay_seconds": 5, + "restart_limit_interval_seconds": 0, + "stop_timeout_seconds": 310, + "runtime_directory_preserve": "restart", + "directory_mode": "0700", + "umask": "0077" + }, + "forbidden_directives": [ + "ConfigDirectory", + "ConfigDirectoryMode", + "DynamicUser", + "Environment", + "EnvironmentFile", + "ExecStartPost", + "ExecStartPre", + "LoadCredential", + "LoadCredentialEncrypted", + "PassEnvironment" + ], + "verification": { + "minimum_systemd_major": 252, + "maximum_exposure_score": 3.0, + "maximum_exposure_percent": 30, + "syntax_check": "systemd-analyze verify", + "security_check": "systemd-analyze security --offline=yes --threshold=30" + }, + "deferred": [ + "compatibility_sensitive_memory_deny_write_execute", + "compatibility_sensitive_system_call_filter", + "resource_limits_step_231", + "integration_wave_step_229", + "rcld_promotion_step_235", + "nix", + "oci", + "deployment", + "production_activation" + ] +} diff --git a/packaging/systemd/rhi@.service b/packaging/systemd/rhi@.service @@ -2,6 +2,7 @@ Description=Radroots RHI evidence service instance %i After=network-online.target Wants=network-online.target +StartLimitIntervalSec=0 [Service] Type=simple @@ -11,8 +12,12 @@ UMask=0077 ExecStart=/usr/bin/rhi --profile service-host --instance %i run Restart=on-failure RestartSec=5s -ConfigDirectory=radroots/services/rhi/%i -ConfigDirectoryMode=0700 +RestartPreventExitStatus=2 4 5 6 +TimeoutStopSec=310s +KillSignal=SIGTERM +FinalKillSignal=SIGKILL +ConfigurationDirectory=radroots/services/rhi/%i +ConfigurationDirectoryMode=0700 StateDirectory=radroots/services/rhi/%i StateDirectoryMode=0700 CacheDirectory=radroots/services/rhi/%i @@ -21,17 +26,30 @@ LogsDirectory=radroots/services/rhi/%i LogsDirectoryMode=0700 RuntimeDirectory=radroots/services/rhi/%i RuntimeDirectoryMode=0700 -RuntimeDirectoryPreserve=yes +RuntimeDirectoryPreserve=restart NoNewPrivileges=yes PrivateTmp=yes +PrivateDevices=yes ProtectHome=yes ProtectSystem=strict +ProtectClock=yes ProtectControlGroups=yes +ProtectHostname=yes +ProtectKernelLogs=yes ProtectKernelModules=yes ProtectKernelTunables=yes +ProtectProc=invisible +ProcSubset=pid RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +RestrictNamespaces=yes +RestrictRealtime=yes RestrictSUIDSGID=yes LockPersonality=yes +CapabilityBoundingSet= +AmbientCapabilities= +KeyringMode=private +RemoveIPC=yes +SystemCallArchitectures=native [Install] WantedBy=multi-user.target diff --git a/scripts/verify-systemd.sh b/scripts/verify-systemd.sh @@ -0,0 +1,45 @@ +#!/bin/sh +set -eu + +repository_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P) +cd "$repository_root" + +if [ "$(uname -s)" != Linux ]; then + echo "systemd_qualification_unavailable: Linux is required" >&2 + exit 1 +fi +if ! command -v systemd-analyze >/dev/null 2>&1; then + echo "systemd_qualification_unavailable: systemd-analyze is required" >&2 + exit 1 +fi + +systemd_major=$(systemd-analyze --version | awk 'NR == 1 { print $2 }') +case "$systemd_major" in + ''|*[!0-9]*) + echo "systemd_qualification_invalid: cannot determine systemd version" >&2 + exit 1 + ;; +esac +if [ "$systemd_major" -lt 252 ]; then + echo "systemd_qualification_invalid: systemd 252 or newer is required" >&2 + exit 1 +fi + +temporary_directory=$(mktemp -d "${TMPDIR:-/tmp}/rhi-systemd.XXXXXX") +cleanup() { + rm -rf -- "$temporary_directory" +} +trap cleanup EXIT HUP INT TERM + +sed 's|^ExecStart=/usr/bin/rhi --profile service-host --instance %i run$|ExecStart=/bin/true|' \ + packaging/systemd/rhi@.service >"$temporary_directory/rhi@.service" +if [ "$(grep -c '^ExecStart=/bin/true$' "$temporary_directory/rhi@.service")" -ne 1 ]; then + echo "systemd_qualification_invalid: exact ExecStart was not admitted" >&2 + exit 1 +fi + +systemd-analyze verify "$temporary_directory/rhi@.service" +systemd-analyze security --offline=yes --threshold=30 --no-pager \ + "$temporary_directory/rhi@.service" >/dev/null + +echo "systemd qualification ok: rhi" diff --git a/tests/services_hardening_native_release.rs b/tests/services_hardening_native_release.rs @@ -194,7 +194,7 @@ fn native_release_contract_and_manifest_metadata_are_exact() { ); for required in [ "ExecStart=/usr/bin/rhi --profile service-host --instance %i run", - "ConfigDirectory=radroots/services/rhi/%i", + "ConfigurationDirectory=radroots/services/rhi/%i", "StateDirectory=radroots/services/rhi/%i", "CacheDirectory=radroots/services/rhi/%i", "LogsDirectory=radroots/services/rhi/%i", @@ -202,6 +202,7 @@ fn native_release_contract_and_manifest_metadata_are_exact() { "UMask=0077", "NoNewPrivileges=yes", "ProtectSystem=strict", + "CapabilityBoundingSet=", ] { assert!(SYSTEMD_UNIT.contains(required), "missing `{required}`"); } diff --git a/tests/services_hardening_systemd.rs b/tests/services_hardening_systemd.rs @@ -0,0 +1,159 @@ +#![forbid(unsafe_code)] + +use std::collections::BTreeSet; + +use serde_json::Value; +use sha2::{Digest, Sha256}; + +const CONTRACT: &str = + include_str!("../contracts/services_hardening/systemd_qualification.v1.json"); +const UNIT: &str = include_str!("../packaging/systemd/rhi@.service"); +const VERIFY_SCRIPT: &str = include_str!("../scripts/verify-systemd.sh"); + +fn contract() -> Value { + serde_json::from_str(CONTRACT).expect("systemd qualification contract") +} + +fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn validate_unit(source: &str, authority: &Value) -> Result<(), String> { + if source.len() > 16_384 || !source.ends_with('\n') || source.contains(['\0', '\r']) { + return Err("invalid unit bytes".to_owned()); + } + + let expected = authority["canonical_lines"] + .as_array() + .ok_or_else(|| "missing canonical lines".to_owned())? + .iter() + .map(|line| { + line.as_str() + .ok_or_else(|| "invalid canonical line".to_owned()) + }) + .collect::<Result<Vec<_>, _>>()?; + let actual = source + .lines() + .filter(|line| !line.is_empty()) + .collect::<Vec<_>>(); + + let forbidden = authority["forbidden_directives"] + .as_array() + .ok_or_else(|| "missing forbidden directives".to_owned())? + .iter() + .map(|directive| { + directive + .as_str() + .ok_or_else(|| "invalid forbidden directive".to_owned()) + }) + .collect::<Result<BTreeSet<_>, _>>()?; + let mut section = ""; + let mut sections = Vec::new(); + let mut keys = BTreeSet::new(); + for line in &actual { + if line.starts_with('[') && line.ends_with(']') { + section = &line[1..line.len() - 1]; + sections.push(section); + continue; + } + let (key, _) = line + .split_once('=') + .ok_or_else(|| "invalid directive".to_owned())?; + if section.is_empty() || forbidden.contains(key) { + return Err("forbidden or unscoped directive".to_owned()); + } + if !keys.insert(format!("{section}.{key}")) { + return Err("duplicate directive".to_owned()); + } + } + if sections != ["Unit", "Service", "Install"] || actual != expected { + return Err("unit differs from canonical contract".to_owned()); + } + Ok(()) +} + +#[test] +fn systemd_contract_binds_the_exact_fail_closed_unit() { + let authority = contract(); + assert_eq!(authority["schema"], "radroots.rhi.systemd-qualification"); + assert_eq!(authority["schema_version"], 1); + assert_eq!(authority["service"], "rhi"); + assert_eq!(authority["unit_path"], "packaging/systemd/rhi@.service"); + assert_eq!( + authority["verification_script"], + "scripts/verify-systemd.sh" + ); + assert_eq!(authority["unit_sha256"], sha256_hex(UNIT.as_bytes())); + validate_unit(UNIT, &authority).expect("canonical systemd unit"); + + assert_eq!(authority["runtime_posture"]["type"], "simple"); + assert_eq!( + authority["runtime_posture"]["readiness"], + "cached_cli_no_sd_notify" + ); + assert_eq!( + authority["runtime_posture"]["restartable_exit_codes"], + serde_json::json!([1, 3]) + ); + assert_eq!( + authority["runtime_posture"]["nonrestartable_exit_codes"], + serde_json::json!([2, 4, 5, 6]) + ); + assert_eq!(authority["runtime_posture"]["stop_timeout_seconds"], 310); + assert_eq!(authority["verification"]["minimum_systemd_major"], 252); + assert_eq!(authority["verification"]["maximum_exposure_score"], 3.0); + assert_eq!(authority["verification"]["maximum_exposure_percent"], 30); +} + +#[test] +fn systemd_unit_rejects_aliases_environment_duplicates_and_weaker_posture() { + let authority = contract(); + let mutations = [ + UNIT.replace("ConfigurationDirectory=", "ConfigDirectory="), + format!("{UNIT}Environment=RHI_SECRET=forbidden\n"), + UNIT.replace( + "NoNewPrivileges=yes", + "NoNewPrivileges=yes\nNoNewPrivileges=yes", + ), + UNIT.replace( + "RuntimeDirectoryPreserve=restart", + "RuntimeDirectoryPreserve=yes", + ), + UNIT.replace("CapabilityBoundingSet=\n", ""), + ]; + for mutation in mutations { + assert!(validate_unit(&mutation, &authority).is_err()); + } +} + +#[test] +fn systemd_verifier_is_linux_only_bounded_and_forge_agnostic() { + for required in [ + "systemd 252 or newer is required", + "systemd-analyze verify", + "--offline=yes --threshold=30", + "exact ExecStart was not admitted", + ] { + assert!(VERIFY_SCRIPT.contains(required), "missing `{required}`"); + } + for forbidden in ["nix ", "oci", ".github", ".act", "_radroots", "docker"] { + assert!(!VERIFY_SCRIPT.to_ascii_lowercase().contains(forbidden)); + } + assert_eq!( + contract()["deferred"], + serde_json::json!([ + "compatibility_sensitive_memory_deny_write_execute", + "compatibility_sensitive_system_call_filter", + "resource_limits_step_231", + "integration_wave_step_229", + "rcld_promotion_step_235", + "nix", + "oci", + "deployment", + "production_activation" + ]) + ); +}