commit 704bcad192dff3f81ac867ea25169cea5d699880
parent 6496dd631e732f61b3d35fc925b93e601022008b
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(+), 4 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -194,6 +194,15 @@
root, arbitrary member name, Nix/NixOS/OCI input or output, signing key,
parent-owned human document, private harness, protected material, or
publication/deployment authority.
+- 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.
- Step 221 integration requires a signer-transport-authored `pending_connection` response for an
explicitly approval-gated NIP-46 connect request. Keep that exact response
and its initial delivery job atomic and immutable without recording a false
@@ -368,6 +377,7 @@ cargo extbuild run -- cargo test --workspace --all-targets --locked
cargo extbuild run -- cargo clippy --workspace --all-targets --locked -- -D warnings
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
@@ -45,6 +45,20 @@ vendors the exact locked Cargo graph and proves an offline metadata read before
packaging. Signing credentials and parent-owned human documentation are never
inputs.
+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.
+
The canonical service source lock binds the exact active public Lib cohort,
Cargo lock, verified Lib source archive, toolchain, feature profile, and
service contract versions. Deferred flake source data is independently
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.myc.systemd-qualification",
+ "schema_version": 1,
+ "service": "myc",
+ "unit_path": "packaging/systemd/myc@.service",
+ "verification_script": "scripts/verify-systemd.sh",
+ "unit_sha256": "6d0c081fac5cb234bf0879a57a496e14d46def3031c79d85cf005312b4f29be9",
+ "canonical_lines": [
+ "[Unit]",
+ "Description=Radroots Myc signer instance %i",
+ "After=network-online.target",
+ "Wants=network-online.target",
+ "StartLimitIntervalSec=0",
+ "[Service]",
+ "Type=simple",
+ "User=myc",
+ "Group=myc",
+ "UMask=0077",
+ "ExecStart=/usr/bin/myc --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/myc/%i",
+ "ConfigurationDirectoryMode=0700",
+ "StateDirectory=radroots/services/myc/%i",
+ "StateDirectoryMode=0700",
+ "CacheDirectory=radroots/services/myc/%i",
+ "CacheDirectoryMode=0700",
+ "LogsDirectory=radroots/services/myc/%i",
+ "LogsDirectoryMode=0700",
+ "RuntimeDirectory=radroots/services/myc/%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/myc@.service b/packaging/systemd/myc@.service
@@ -2,6 +2,7 @@
Description=Radroots Myc signer instance %i
After=network-online.target
Wants=network-online.target
+StartLimitIntervalSec=0
[Service]
Type=simple
@@ -11,8 +12,12 @@ UMask=0077
ExecStart=/usr/bin/myc --profile service-host --instance %i run
Restart=on-failure
RestartSec=5s
-ConfigDirectory=radroots/services/myc/%i
-ConfigDirectoryMode=0700
+RestartPreventExitStatus=2 4 5 6
+TimeoutStopSec=310s
+KillSignal=SIGTERM
+FinalKillSignal=SIGKILL
+ConfigurationDirectory=radroots/services/myc/%i
+ConfigurationDirectoryMode=0700
StateDirectory=radroots/services/myc/%i
StateDirectoryMode=0700
CacheDirectory=radroots/services/myc/%i
@@ -21,17 +26,30 @@ LogsDirectory=radroots/services/myc/%i
LogsDirectoryMode=0700
RuntimeDirectory=radroots/services/myc/%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}/myc-systemd.XXXXXX")
+cleanup() {
+ rm -rf -- "$temporary_directory"
+}
+trap cleanup EXIT HUP INT TERM
+
+sed 's|^ExecStart=/usr/bin/myc --profile service-host --instance %i run$|ExecStart=/bin/true|' \
+ packaging/systemd/myc@.service >"$temporary_directory/myc@.service"
+if [ "$(grep -c '^ExecStart=/bin/true$' "$temporary_directory/myc@.service")" -ne 1 ]; then
+ echo "systemd_qualification_invalid: exact ExecStart was not admitted" >&2
+ exit 1
+fi
+
+systemd-analyze verify "$temporary_directory/myc@.service"
+systemd-analyze security --offline=yes --threshold=30 --no-pager \
+ "$temporary_directory/myc@.service" >/dev/null
+
+echo "systemd qualification ok: myc"
diff --git a/tests/services_hardening_native_release.rs b/tests/services_hardening_native_release.rs
@@ -190,12 +190,13 @@ fn native_release_contract_and_manifest_metadata_are_exact() {
);
for required in [
"ExecStart=/usr/bin/myc --profile service-host --instance %i run",
- "ConfigDirectory=radroots/services/myc/%i",
+ "ConfigurationDirectory=radroots/services/myc/%i",
"StateDirectory=radroots/services/myc/%i",
"RuntimeDirectory=radroots/services/myc/%i",
"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/myc@.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.myc.systemd-qualification");
+ assert_eq!(authority["schema_version"], 1);
+ assert_eq!(authority["service"], "myc");
+ assert_eq!(authority["unit_path"], "packaging/systemd/myc@.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=MYC_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"
+ ])
+ );
+}