Merge idunn/isolation-credential-evidence: delivered credentials, not LoadCredential

Cut idunn-deploy-provenance:cut_spec:cut-cut-isolation-credential-evidence.r1,
report h1, Soul s1 (all claims hold). systemd 257 prints LoadCredential as
[unprintable]; the workload's credential directory, read from its MainPID's
root, must hold exactly the delivered names. Unblocks Vili's promotion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-24 16:05:26 +02:00
commit 804ed759c9
6 changed files with 335 additions and 45 deletions

View file

@ -470,6 +470,9 @@ pub struct SystemdWorkloadObservation {
pub no_new_privileges: bool,
pub umask: String,
pub inaccessible_paths: String,
/// Retired wire slot. systemd 257 displays `LoadCredential` as `[unprintable]` for
/// every unit, so it was never evidence: written empty, never compared. Kept so
/// compact (positional) MessagePack records of earlier Idunns still decode.
pub load_credential: String,
pub main_pid: u32,
pub process_start_time: u64,
@ -2459,7 +2462,6 @@ fn changed_systemd_workload_identity_fields(
no_new_privileges,
umask,
inaccessible_paths,
load_credential,
main_pid,
process_start_time,
process_uids,
@ -2859,7 +2861,6 @@ impl SystemdTransientWorkloadDriver {
OsString::from("--property=NoNewPrivileges"),
OsString::from("--property=UMask"),
OsString::from("--property=InaccessiblePaths"),
OsString::from("--property=LoadCredential"),
OsString::from("--property=OpenFile"),
OsString::from("--property=WorkingDirectory"),
OsString::from("--property=ControlGroup"),
@ -2971,38 +2972,7 @@ impl SystemdTransientWorkloadDriver {
let umask = required_systemd_property(&values, "UMask")?.to_owned();
let inaccessible_paths =
required_systemd_property(&values, "InaccessiblePaths")?.to_owned();
let load_credential = required_systemd_property(&values, "LoadCredential")?.to_owned();
ensure!(
service_type == "exec"
&& restart_policy == "no"
&& kill_mode == "mixed"
// `User` is deliberately not asserted here. Idunn never emits a
// User property, and systemd reports the identity it *allocated*
// for a DynamicUser unit -- the unit name, or a generated `_du…`
// when that is too long -- so the property is never empty for a
// running workload and cannot tell an allocated identity from a
// pinned one. The invariant it was reaching for, that the workload
// does not run as a persistent named account, is carried by
// `dynamic_user` below and by Idunn never passing User at all.
&& dynamic_user
&& supplementary_groups.is_empty()
&& capability_bounding_set.is_empty()
&& ambient_capabilities.is_empty()
&& private_mounts
&& private_pids
&& protect_proc == "invisible"
&& proc_subset == "all"
&& no_new_privileges
&& umask == "0007"
&& inaccessible_paths == self.credential_root.display().to_string()
&& load_credential
== if service_credential_names.is_empty() {
""
} else {
"[unprintable]"
},
"systemd workload isolation properties differ from the Idunn contract"
);
require_systemd_isolation_contract(&values, &self.credential_root)?;
let working_directory =
PathBuf::from(required_systemd_property(&values, "WorkingDirectory")?);
ensure!(
@ -3144,6 +3114,11 @@ impl SystemdTransientWorkloadDriver {
);
Some(path)
};
require_delivered_credentials(
&process_root.join("root"),
unit,
service_credential_names,
)?;
let activation_descriptor = &parent_only_file_descriptors[0];
ensure!(
activation_descriptor.fd_name == IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME
@ -3251,7 +3226,7 @@ impl SystemdTransientWorkloadDriver {
no_new_privileges,
umask,
inaccessible_paths,
load_credential,
load_credential: String::new(),
main_pid,
process_start_time,
process_uids: process_security.uids,
@ -3465,12 +3440,7 @@ impl SystemdTransientWorkloadDriver {
// workload. The same applies to `Group` when no fixed state group is
// bound. In that case the numeric primary GID must match the allocated
// UID; a fixed state group still requires both its exact name and GID.
let expected_load_credential = if regular_credential_names.is_empty() {
""
} else {
"[unprintable]"
};
let launch_contract: [(bool, &str); 27] = [
let launch_contract: [(bool, &str); 26] = [
(
observation.unit_description == expected_description,
"unit description",
@ -3510,10 +3480,6 @@ impl SystemdTransientWorkloadDriver {
observation.inaccessible_paths == self.credential_root.display().to_string(),
"inaccessible paths",
),
(
observation.load_credential == expected_load_credential,
"load credential",
),
(
observation.working_directory == installed,
"working directory",
@ -7269,6 +7235,44 @@ fn path_inside_process_root(process_root: &Path, absolute_path: &Path) -> Result
Ok(process_root.join(absolute_path.strip_prefix(Path::new("/"))?))
}
/// The workload holds exactly the credentials Idunn delivered: the entries of
/// `/run/credentials/<unit>` in the MainPID's root (`process_root` is that root)
/// equal `expected`, and an absent directory is the empty set. Only entry names
/// are read; a credential is never opened here. The message names no credential.
fn require_delivered_credentials(
process_root: &Path,
unit: &str,
expected: &[String],
) -> Result<()> {
let directory = path_inside_process_root(
process_root,
&Path::new("/run/credentials").join(unit),
)?;
let mut delivered = Vec::new();
match fs::read_dir(&directory) {
Ok(entries) => {
for entry in entries {
let name = entry
.context("reading a delivered credential entry")?
.file_name()
.into_string()
.map_err(|_| anyhow!("delivered credential name is not UTF-8"))?;
delivered.push(name);
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(error).context("reading the delivered credential directory");
}
}
delivered.sort();
ensure!(
delivered == expected,
"systemd delivered credentials that differ from the Idunn launch"
);
Ok(())
}
fn linux_process_security(
status_path: &Path,
expected_host_pid: u32,
@ -7484,6 +7488,56 @@ fn parse_systemd_boolean(values: &BTreeMap<String, String>, name: &str) -> Resul
}
}
/// The isolation properties of a transient workload unit, judged from `systemctl
/// show`. `LoadCredential` is absent on purpose: systemd 257 displays it as
/// `[unprintable]` whatever the unit holds, so what the workload was given is
/// judged by `require_delivered_credentials` from the delivered directory.
fn require_systemd_isolation_contract(
values: &BTreeMap<String, String>,
credential_root: &Path,
) -> Result<()> {
let service_type = required_systemd_property(values, "Type")?;
let restart_policy = required_systemd_property(values, "Restart")?;
let kill_mode = required_systemd_property(values, "KillMode")?;
let dynamic_user = parse_systemd_boolean(values, "DynamicUser")?;
let supplementary_groups = systemd_property(values, "SupplementaryGroups")?;
let capability_bounding_set = systemd_property(values, "CapabilityBoundingSet")?;
let ambient_capabilities = systemd_property(values, "AmbientCapabilities")?;
let private_mounts = parse_systemd_boolean(values, "PrivateMounts")?;
let private_pids = parse_systemd_boolean(values, "PrivatePIDs")?;
let protect_proc = required_systemd_property(values, "ProtectProc")?;
let proc_subset = required_systemd_property(values, "ProcSubset")?;
let no_new_privileges = parse_systemd_boolean(values, "NoNewPrivileges")?;
let umask = required_systemd_property(values, "UMask")?;
let inaccessible_paths = required_systemd_property(values, "InaccessiblePaths")?;
ensure!(
service_type == "exec"
&& restart_policy == "no"
&& kill_mode == "mixed"
// `User` is deliberately not asserted here. Idunn never emits a
// User property, and systemd reports the identity it *allocated*
// for a DynamicUser unit -- the unit name, or a generated `_du…`
// when that is too long -- so the property is never empty for a
// running workload and cannot tell an allocated identity from a
// pinned one. The invariant it was reaching for, that the workload
// does not run as a persistent named account, is carried by
// `dynamic_user` below and by Idunn never passing User at all.
&& dynamic_user
&& supplementary_groups.is_empty()
&& capability_bounding_set.is_empty()
&& ambient_capabilities.is_empty()
&& private_mounts
&& private_pids
&& protect_proc == "invisible"
&& proc_subset == "all"
&& no_new_privileges
&& umask == "0007"
&& inaccessible_paths == credential_root.display().to_string(),
"systemd workload isolation properties differ from the Idunn contract"
);
Ok(())
}
fn read_process_environment(path: &Path) -> Result<Vec<Vec<u8>>> {
let bytes = fs::read(path)
.with_context(|| format!("reading process environment {}", path.display()))?;
@ -9968,6 +10022,168 @@ processes 9
);
}
#[cfg(unix)]
fn fixture_show_properties(text: &str) -> Result<BTreeMap<String, String>> {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir()?;
let shown = temp.path().join("shown");
std::fs::write(&shown, text)?;
let systemctl = temp.path().join("systemctl");
std::fs::write(
&systemctl,
format!("#!/bin/sh\ncat '{}'\nexit 0\n", shown.display()),
)?;
std::fs::set_permissions(&systemctl, std::fs::Permissions::from_mode(0o755))?;
let driver = SystemdTransientWorkloadDriver {
systemctl_program: systemctl,
..Default::default()
};
Ok(driver
.show_unit("idunn-test.service")?
.context("fixture unit was not found")?
.properties)
}
/// systemd 257 prints `LoadCredential=[unprintable]` for every unit, so the
/// isolation contract must admit the real output of a unit with no service
/// credential as well as one with one. The inputs are real captures, not a
/// re-spelling.
#[cfg(unix)]
#[test]
fn systemd_257_isolation_contract_admits_a_presence_only_target_and_one_with_service_credentials(
) -> Result<()> {
let root = Path::new("/run/idunn/activation-credentials");
let vili = include_str!("../tests/fixtures/systemd-257/vili-presence-identity-only.show");
let odin = include_str!(
"../tests/fixtures/systemd-257/odin-presence-and-one-service-credential.show"
);
for text in [vili, odin] {
assert!(text.contains("LoadCredential=[unprintable]"));
require_systemd_isolation_contract(&fixture_show_properties(text)?, root)?;
}
let default_proc = vili.replace("ProtectProc=invisible", "ProtectProc=default");
assert_ne!(default_proc, vili);
let other_path = vili.replace(
"/run/idunn/activation-credentials",
"/run/idunn/elsewhere",
);
assert_ne!(other_path, vili);
for text in [default_proc, other_path] {
let error =
require_systemd_isolation_contract(&fixture_show_properties(&text)?, root)
.expect_err("a drifted isolation property must be refused");
assert_eq!(
error.to_string(),
"systemd workload isolation properties differ from the Idunn contract"
);
}
Ok(())
}
#[cfg(unix)]
#[test]
fn delivered_credentials_are_exactly_the_launch_names() -> Result<()> {
let unit = "idunn-test.service";
let names = |list: &[&str]| list.iter().map(|name| name.to_string()).collect::<Vec<_>>();
// `Some(entries)` creates the unit's directory in a fresh process root.
let check = |delivered: Option<&[&str]>, expected: &[&str]| -> Result<()> {
let temp = tempfile::tempdir()?;
if let Some(entries) = delivered {
let directory = temp.path().join("run/credentials").join(unit);
std::fs::create_dir_all(&directory)?;
for entry in entries {
std::fs::write(directory.join(entry), "canary-credential-contents")?;
}
}
let result = require_delivered_credentials(temp.path(), unit, &names(expected));
if let Err(error) = &result {
let text = format!("{error:#}");
for canary in ["STOWAWAY", "ODIN_TOPOLOGY_IDENTITY", "canary-credential"] {
assert!(!text.contains(canary), "error echoed {canary}");
}
assert!(!text.contains(&*temp.path().to_string_lossy()));
}
result
};
check(None, &[])?;
check(Some(&[]), &[])?;
check(Some(&["ODIN_TOPOLOGY_IDENTITY"]), &["ODIN_TOPOLOGY_IDENTITY"])?;
check(Some(&["B", "A"]), &["A", "B"])?;
for (delivered, expected) in [
(None, &["ODIN_TOPOLOGY_IDENTITY"][..]),
(Some(&[][..]), &["ODIN_TOPOLOGY_IDENTITY"][..]),
(
Some(&["ODIN_TOPOLOGY_IDENTITY", "STOWAWAY"][..]),
&["ODIN_TOPOLOGY_IDENTITY"][..],
),
(Some(&["STOWAWAY"][..]), &[][..]),
(Some(&["A"][..]), &["B"][..]),
(Some(&["A"][..]), &["A", "B"][..]),
] {
let error = check(delivered, expected)
.expect_err("delivered credentials differing from the launch must be refused");
assert_eq!(
error.to_string(),
"systemd delivered credentials that differ from the Idunn launch"
);
}
// A directory that cannot be listed is a refusal, never the empty set.
let temp = tempfile::tempdir()?;
let parent = temp.path().join("run/credentials");
std::fs::create_dir_all(&parent)?;
std::fs::write(parent.join(unit), "")?;
let error = require_delivered_credentials(temp.path(), unit, &[])
.expect_err("an unreadable credential directory must be refused");
assert!(!format!("{error:#}").contains(&*temp.path().to_string_lossy()));
// The rule reads the process root it is given, never the host's /run.
let temp = tempfile::tempdir()?;
let outside = tempfile::tempdir()?;
let directory = outside.path().join("run/credentials").join(unit);
std::fs::create_dir_all(&directory)?;
std::fs::write(directory.join("A"), "")?;
require_delivered_credentials(temp.path(), unit, &[])?;
assert!(require_delivered_credentials(temp.path(), unit, &names(&["A"])).is_err());
Ok(())
}
/// Live Odin and StreamPixels hold records persisted by the Idunn that
/// compared `LoadCredential`: `[unprintable]` in the retired slot. The bytes
/// are that Idunn's own output (tests/fixtures/pre-cut-*); they must decode
/// under both encodings and read as no drift against a new observation, or
/// `stop` refuses to stop the incumbents.
#[test]
fn an_incumbent_recorded_with_the_display_form_is_not_identity_drift() -> Result<()> {
let decode_hex = |text: &str| -> Vec<u8> {
(0..text.trim().len())
.step_by(2)
.map(|at| u8::from_str_radix(&text.trim()[at..at + 2], 16).unwrap())
.collect()
};
let mut recorded = systemd_audit_observation("no", 61_000);
recorded.load_credential = "[unprintable]".into();
let observed = systemd_audit_observation("no", 61_000);
assert!(observed.load_credential.is_empty());
for hex in [
include_str!("../tests/fixtures/pre-cut-systemd-observation.compact.hex"),
include_str!("../tests/fixtures/pre-cut-systemd-observation.named.hex"),
] {
let decoded: WorkloadObservation = rmp_serde::from_slice(&decode_hex(hex))?;
assert_eq!(decoded, WorkloadObservation::Systemd(recorded.clone()));
let WorkloadObservation::Systemd(prior) = decoded else {
unreachable!()
};
assert!(changed_systemd_workload_identity_fields(&observed, &prior).is_empty());
}
let mut drifted = observed.clone();
drifted.main_pid += 1;
assert_eq!(
changed_systemd_workload_identity_fields(&drifted, &recorded),
vec!["main_pid"]
);
Ok(())
}
#[test]
fn host_isolation_is_two_live_distinct_processes_on_one_host() {
let live = |pid: u32, created: u64| {

View file

@ -10,3 +10,19 @@ It is checked in because the TypeScript runtime emits a camelCase MessagePack
map while Rust `DatabaseEntry` derives tuple payloads. Idunn must prove it can
decode the producer's real wire shape rather than round-tripping a fixture it
serialized itself.
# systemd 257 `systemctl show` captures
`systemd-257/*.show` are the verbatim output of systemd 257.13 for the 27
properties `show_unit` requested at `112a7a5`, captured read-only on Yggdrasil
on 2026-10-24: `vili-presence-identity-only.show` (a failed idunn-vili unit whose
only credential is the presence identity) and
`odin-presence-and-one-service-credential.show` (the live Odin unit). systemd 257
prints `LoadCredential=[unprintable]` in both. Tests read these bytes; do not edit.
# Pre-cut systemd observation records
`pre-cut-systemd-observation.{compact,named}.hex` are the two MessagePack
encodings of a `SystemdWorkloadObservation` with `load_credential` set to
`[unprintable]`, produced by the code at `112a7a5` (the form live incumbents
persisted). They pin that later Idunns still decode such records.

View file

@ -0,0 +1 @@
dc0033d94e6964756e6e2d616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161612e73657276696365aa4964756e6e2074657374a3696e7601a465786563a26e6fa56d69786564c3a6753631303030a6753631303030a0a0a0c3c3a9696e76697369626c65a3616c6cc3a430303037a0ad5b756e7072696e7461626c655dcd10920194cdee48cdee48cdee48cdee4894cdee48cdee48cdee48cdee4891cdee480000000000c391010708b52f6f70742f746573742f62696e2f736572766963650101d9477368613235362d31313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131d9477368613235362d61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161a92f6f70742f74657374a92f72756e2f74657374d9477368613235362d3232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323290d9477368613235362d33333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333d9202f73797374656d2e736c6963652f6964756e6e2d746573742e73657276696365c090aa61637469766174696f6edc0020010101010101010101010101010101010101010101010101010101010101010190

View file

@ -0,0 +1 @@
de0033a4756e6974d94e6964756e6e2d616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161612e73657276696365b0756e69745f6465736372697074696f6eaa4964756e6e2074657374ad696e766f636174696f6e5f6964a3696e76d923657865635f6d61696e5f73746172745f74696d657374616d705f6d6f6e6f746f6e696301ac736572766963655f74797065a465786563ae726573746172745f706f6c696379a26e6fa96b696c6c5f6d6f6465a56d69786564ac64796e616d69635f75736572c3ac73797374656d645f75736572a6753631303030ad73797374656d645f67726f7570a6753631303030b4737570706c656d656e746172795f67726f757073a0b76361706162696c6974795f626f756e64696e675f736574a0b4616d6269656e745f6361706162696c6974696573a0ae707269766174655f6d6f756e7473c3ac707269766174655f70696473c3ac70726f746563745f70726f63a9696e76697369626c65ab70726f635f737562736574a3616c6cb16e6f5f6e65775f70726976696c65676573c3a5756d61736ba430303037b2696e61636365737369626c655f7061746873a0af6c6f61645f63726564656e7469616cad5b756e7072696e7461626c655da86d61696e5f706964cd1092b270726f636573735f73746172745f74696d6501ac70726f636573735f7569647394cdee48cdee48cdee48cdee48ac70726f636573735f6769647394cdee48cdee48cdee48cdee48ae70726f636573735f67726f75707391cdee48b770726f636573735f6361705f696e686572697461626c6500b570726f636573735f6361705f7065726d697474656400b570726f636573735f6361705f65666665637469766500b470726f636573735f6361705f626f756e64696e6700b370726f636573735f6361705f616d6269656e7400b970726f636573735f6e6f5f6e65775f70726976696c65676573c3b670726f636573735f6e616d6573706163655f706964739101b26d6f756e745f6e616d6573706163655f696407b07069645f6e616d6573706163655f696408aa65786563757461626c65b52f6f70742f746573742f62696e2f73657276696365b165786563757461626c655f64657669636501b065786563757461626c655f696e6f646501b165786563757461626c655f736861323536d9477368613235362d31313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131b372756e74696d655f696e7374616e63655f6964d9477368613235362d61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161b1776f726b696e675f6469726563746f7279a92f6f70742f74657374ae72756e74696d655f62756e646c65a92f72756e2f74657374b3636f6d6d616e645f6c696e655f736861323536d9477368613235362d32323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232b1656e7669726f6e6d656e745f6e616d657390bb656e7669726f6e6d656e745f636f6e74726163745f736861323536d9477368613235362d33333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333ad636f6e74726f6c5f67726f7570d9202f73797374656d2e736c6963652f6964756e6e2d746573742e73657276696365b563726564656e7469616c735f6469726563746f7279c0bc706172656e745f6f6e6c795f66696c655f64657363726970746f727390bd61637469766174696f6e5f7369676e65725f6964656e746974795f6964aa61637469766174696f6ebc61637469766174696f6e5f7369676e65725f7075626c69635f6b6579dc00200101010101010101010101010101010101010101010101010101010101010101b3736572766963655f63726564656e7469616c7390

View file

@ -0,0 +1,28 @@
Type=exec
Restart=no
MainPID=2571
OpenFile=/run/idunn/activation-credentials/sha256-7cfe5cd1e87839e029c7425eb1b586b6b0d94dd79d7376eed3b156386f2c7af8.credential:gamecult-idunn-runtime-activation-key:read-only
OpenFile=/etc/gamecult/odin/runtime-presence-identity.cc:gamecult-runtime-presence-identity:read-only
ExecMainStartTimestampMonotonic=20630000
ControlGroup=/system.slice/idunn-odin-7cfe5cd1e87839e029c7425eb1b586b6b0d94dd79d7376eed3b156386f2c7af8.service
UMask=0007
WorkingDirectory=/srv/odin/releases/sha256-e5c7d0b2b9c0a8ffba368aeb08abdbe37ae0d17484a785e60a66ec4a62b2f18c
CapabilityBoundingSet=
AmbientCapabilities=
User=_duc23369f1b8b167db
Group=odin
DynamicUser=yes
LoadCredential=[unprintable]
SupplementaryGroups=
InaccessiblePaths=/run/idunn/activation-credentials
PrivateMounts=yes
PrivatePIDs=yes
NoNewPrivileges=yes
ProtectProc=invisible
ProcSubset=all
KillMode=mixed
Description=Idunn odin sha256-7cfe5cd1e87839e029c7425eb1b586b6b0d94dd79d7376eed3b156386f2c7af8
LoadState=loaded
ActiveState=active
SubState=running
InvocationID=ada48921043449f39dacc87b14a1bf70

View file

@ -0,0 +1,28 @@
Type=exec
Restart=no
MainPID=0
OpenFile=/run/idunn/activation-credentials/sha256-b507e1705a70f8eee72f355e7db637acab8eb37880b5d25245fda68d80f63041.credential:gamecult-idunn-runtime-activation-key:read-only
OpenFile=/etc/gamecult/vili/runtime-presence-identity.cc:gamecult-runtime-presence-identity:read-only
ExecMainStartTimestampMonotonic=520083420000
ControlGroup=
UMask=0007
WorkingDirectory=/srv/vili-idunn/releases/sha256-aeaee4e1669742572c45a3f26bc86893c44b2a4149520975eb7d8a28aa931906
CapabilityBoundingSet=
AmbientCapabilities=
User=_du8c06c563aac6d205
Group=vili-state
DynamicUser=yes
LoadCredential=[unprintable]
SupplementaryGroups=
InaccessiblePaths=/run/idunn/activation-credentials
PrivateMounts=yes
PrivatePIDs=yes
NoNewPrivileges=yes
ProtectProc=invisible
ProcSubset=all
KillMode=mixed
Description=Idunn vili sha256-b507e1705a70f8eee72f355e7db637acab8eb37880b5d25245fda68d80f63041
LoadState=loaded
ActiveState=failed
SubState=failed
InvocationID=8f7a349f2f8f49fe87ac5d89239260b4