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:
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"
+ ])
+ );
+}