myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

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:
MAGENTS.md | 10++++++++++
MREADME | 14++++++++++++++
Acontracts/services_hardening/systemd_qualification.v1.json | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackaging/systemd/myc@.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(+), 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" + ]) + ); +}