diff --git a/src/control_plane.rs b/src/control_plane.rs index 10b92a3..dfd2496 100644 --- a/src/control_plane.rs +++ b/src/control_plane.rs @@ -6225,6 +6225,276 @@ mod tests { Ok(()) } + // --------------------------------------------------------------------- + // Admission audit (soul/admission-audit). Each test below is a negative + // check on one claim made by c09993c..8448f83; none asserts that the + // happy path works, the tests above already do that. + // --------------------------------------------------------------------- + + impl TopologyFixture { + /// Authenticate with an explicit trusted time, against this fixture's + /// own authority and Odin key. Mirrors `rehydrate_ready_token`'s two + /// callers: `now` when require_current, `admitted_at` otherwise. + fn authenticate_at( + &self, + canonical: &[u8], + trusted_received_at_unix_millis: u64, + ) -> Result { + authenticate_odin_runtime_topology_correlation( + canonical, + &self.authority()?, + None, + &self.odin_signer.entry().public_key, + OdinTopologyAuthenticationContext { + trusted_received_at_unix_millis, + maximum_age_millis: DEFAULT_TOPOLOGY_MAXIMUM_AGE_MILLIS, + maximum_future_skew_millis: DEFAULT_TOPOLOGY_MAXIMUM_FUTURE_SKEW_MILLIS, + }, + ) + } + } + + /// Claim 1: deleting `observation_describes_declared_incarnation` opened + /// nothing because authentication binds the same identity. The strongest + /// attack is a correlation that is internally perfect for ANOTHER + /// incarnation of the same target -- its own projection, its own activation + /// digest, its own instance id -- signed by OUR admitted Odin, so the only + /// thing left to refuse it is the incarnation binding itself. + #[test] + fn a_perfect_correlation_about_another_incarnation_of_this_target_is_refused() -> Result<()> { + let ours = TopologyFixture::new("odin")?; + let theirs = TopologyFixture::new("odin")?; + assert_ne!( + ours.expected.canonical_sha256()?, + theirs.expected.canonical_sha256()?, + "fixtures must declare distinct incarnations for this test to mean anything" + ); + // Sanity: it is a real, Ready correlation for the other incarnation. + let mut genuine = theirs.correlation(501_713, true)?; + let genuine = theirs.sign(&mut genuine)?; + assert!(is_semantic_ready(&theirs.authenticate(&genuine)?)); + + let mut record = theirs.correlation(501_713, true)?; + let canonical = ours.sign(&mut record)?; + let error = ours + .authenticate(&canonical) + .expect_err("another incarnation's correlation must not authenticate against ours"); + assert!( + format!("{error:#}").contains("substitutes or omits Expected authority"), + "{error:#}" + ); + Ok(()) + } + + /// Claim 1, second face: same Expected, different activation. This is the + /// case the deleted gate could not see at all -- it compared instance ids + /// only -- and the one that matters when a process is re-activated under + /// the same projection. Authentication compares the activation digest. + #[test] + fn a_correlation_bound_to_another_activation_of_the_same_expected_is_refused() -> Result<()> { + let world = TopologyFixture::new("odin")?; + let other = TopologyFixture::new("odin")?; + + let mut reactivated = world.correlation(501_713, true)?; + reactivated.current_activation_sha256 = Some(other.activation.canonical_sha256()?); + let canonical = world.sign(&mut reactivated)?; + let error = world + .authenticate(&canonical) + .expect_err("activation digest must bind"); + assert!( + format!("{error:#}").contains("does not bind the current activation"), + "{error:#}" + ); + + let mut anonymous = world.correlation(501_714, true)?; + anonymous.current_activation_sha256 = None; + let canonical = world.sign(&mut anonymous)?; + assert!( + world.authenticate(&canonical).is_err(), + "absent activation must not read as ours" + ); + + let mut nameless = world.correlation(501_715, true)?; + nameless.runtime_instance_id = None; + let canonical = world.sign(&mut nameless)?; + assert!( + world.authenticate(&canonical).is_err(), + "absent instance id must not read as ours" + ); + Ok(()) + } + + /// Claim 1, third face: the deleted gate also compared `target`. The + /// projection digest already covers the target, but a record can carry a + /// foreign `target` field beside our projection digest; prove + /// `validate_against_expected` refuses that on its own. + #[test] + fn a_correlation_naming_another_target_beside_our_projection_is_refused() -> Result<()> { + let world = TopologyFixture::new("odin")?; + let mut stranger = world.correlation(501_713, true)?; + stranger.target = "ghostlight".into(); + let canonical = world.sign(&mut stranger)?; + let error = world.authenticate(&canonical).expect_err("target must bind"); + assert!( + format!("{error:#}").contains("substitutes or omits Expected authority"), + "{error:#}" + ); + Ok(()) + } + + /// Claim 4: the fixture's chain can refuse at every link, not only at the + /// correlation signature. If any of these passed, the tests above would be + /// asserting against a rubber stamp. + #[test] + fn the_fixture_chain_refuses_at_every_link() -> Result<()> { + let ours = TopologyFixture::new("odin")?; + let theirs = TopologyFixture::new("odin")?; + + // Authority link: activation must bind this Expected ... + assert!( + verify_runtime_authority( + &ours.expected, + &theirs.activation, + &ours.idunn_anchor, + &ours.provider_public_key + ) + .is_err(), + "another incarnation's activation verified against our Expected" + ); + // ... be signed by the Idunn we trust ... + assert!( + verify_runtime_authority( + &ours.expected, + &ours.activation, + &theirs.idunn_anchor, + &ours.provider_public_key + ) + .is_err(), + "activation verified against a foreign Idunn anchor" + ); + // ... and name the provider key the Expected selected. + assert!( + verify_runtime_authority( + &ours.expected, + &ours.activation, + &ours.idunn_anchor, + &theirs.provider_public_key + ) + .is_err(), + "a foreign provider key was accepted as the Expected signer" + ); + + // Odin link: a correlation signed by another Odin identity. + let mut record = ours.correlation(501_713, true)?; + let canonical = theirs.sign(&mut record)?; + let error = ours + .authenticate(&canonical) + .expect_err("foreign Odin signer accepted"); + assert!( + format!("{error:#}").contains("names a different identity"), + "{error:#}" + ); + + // Odin link: our identity id, their signature bytes. + let mut record = ours.correlation(501_713, true)?; + record.signature = theirs + .odin_signer + .sign::(&record.unsigned_signature_payload()?) + .signature; + let error = ours + .authenticate(&record.canonical_bytes()?) + .expect_err("forged signature accepted"); + assert!( + format!("{error:#}").contains("signature verification failed"), + "{error:#}" + ); + Ok(()) + } + + /// Claim 2: `rehydrate_ready_token(.., require_current = false)` replays the + /// receipt's authentication at its own `admitted_at`, so the window it + /// widens is exactly the one the receipt already passed at admission. Pin + /// both halves: at `admitted_at` the receipt authenticates however old it + /// is now; at `now` past the window it does not. An `admitted_at` earlier + /// than the observation is refused by the future-skew bound, so the field + /// cannot be pulled backwards to launder a later observation either. + #[test] + fn a_durable_receipt_replays_at_its_admission_time_and_not_at_now() -> Result<()> { + let world = TopologyFixture::new("odin")?; + let mut record = world.correlation(3_841, true)?; + let canonical = world.sign(&mut record)?; + let admitted_at = NOW + 5; + + world.authenticate_at(&canonical, admitted_at)?; + let much_later = admitted_at + DEFAULT_TOPOLOGY_MAXIMUM_AGE_MILLIS + 1; + assert!( + world.authenticate_at(&canonical, much_later).is_err(), + "require_current would refuse" + ); + world.authenticate_at(&canonical, admitted_at)?; + + let before_observation = NOW - DEFAULT_TOPOLOGY_MAXIMUM_FUTURE_SKEW_MILLIS - 1; + assert!( + world.authenticate_at(&canonical, before_observation).is_err(), + "an admitted_at earlier than the observation must not authenticate" + ); + Ok(()) + } + + /// Claim 5 companion: what the live store holds, not only whether the + /// stuck record would commit. Run with: + /// IDUNN_LIVE_CONTROL=/path/to/control.cc cargo test live_store_inventory -- --ignored --nocapture + #[test] + #[ignore] + fn live_store_inventory() -> Result<()> { + let Ok(path) = std::env::var("IDUNN_LIVE_CONTROL") else { + eprintln!("IDUNN_LIVE_CONTROL unset"); + return Ok(()); + }; + let snapshot = ControlSnapshot::read(Path::new(&path))?; + println!( + "INVENTORY commands={} transactions={} admitted={}", + snapshot.commands.len(), + snapshot.transactions.len(), + snapshot.admitted.len() + ); + for stored in &snapshot.transactions { + let value = &stored.value; + println!( + "TX {} target={} kind={:?} phase={:?} complete={} ready_seq={:?} latest_seq={:?} incumbent={:?}", + value.transaction_id, + value.target, + value.command_kind, + value.phase, + value.completion.is_some(), + value.ready.as_ref().map(|evidence| evidence.publisher_sequence), + value + .latest_odin_observation + .as_ref() + .map(|evidence| evidence.publisher_sequence), + value.incumbent_generation_id, + ); + } + for stored in &snapshot.admitted { + let value = &stored.value; + println!( + "ADMITTED target={} generation={} tx={} admitted_at={} ready_seq={} ready_admitted_at={} latest_seq={} latest_admitted_at={} instance={} unit={} restart={}", + value.target, + value.generation_id, + value.transaction_id, + value.admitted_at_unix_millis, + value.ready.publisher_sequence, + value.ready.admitted_at_unix_millis, + value.latest_odin_observation.publisher_sequence, + value.latest_odin_observation.admitted_at_unix_millis, + value.activation.runtime_instance_id, + value.workload.unit, + value.workload.restart_policy, + ); + } + Ok(()) + } + fn topology(sequence: u64, byte: u8) -> TopologyEvidence { let canonical_bytes = vec![byte]; TopologyEvidence { diff --git a/src/drivers.rs b/src/drivers.rs index 83a7516..7a4429d 100644 --- a/src/drivers.rs +++ b/src/drivers.rs @@ -6690,6 +6690,144 @@ fn apply_identity(command: &mut Command, identity: Option) -> R mod tests { use super::*; + /// Admission audit, claim 3: `is_permanently_stopped` now lets the + /// Isolation gate skip `prove_isolation` against the incumbent. Enumerate + /// what it answers `true` for, using a fake `systemctl show` that replays + /// whatever unit state the test writes. The states that matter are the + /// transitional ones -- a restarting, activating, or briefly inactive unit + /// must read as NOT permanently stopped -- and a systemctl failure must be + /// an error, never a quiet "stopped". + #[cfg(unix)] + #[test] + fn is_permanently_stopped_only_for_failed_or_forgotten_units() -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir()?; + let systemctl = temp.path().join("systemctl"); + let state = temp.path().join("systemctl.state"); + let fail = temp.path().join("systemctl.fail"); + std::fs::write( + &systemctl, + "#!/bin/sh\nif [ \"$1\" != \"show\" ]; then exit 64; fi\ncat \"$0.state\"\nif [ -e \"$0.fail\" ]; then exit 1; fi\nexit 0\n", + )?; + std::fs::set_permissions(&systemctl, std::fs::Permissions::from_mode(0o755))?; + let driver = SystemdTransientWorkloadDriver { + systemctl_program: systemctl, + ..Default::default() + }; + let observation = audit_workload_observation("no"); + + let show = |load: &str, active: &str, sub: &str| -> String { + format!("LoadState={load}\nActiveState={active}\nSubState={sub}\nInvocationID=inv\nDescription=Idunn test\n") + }; + for (active, sub) in [ + ("active", "running"), + ("activating", "start"), + ("activating", "auto-restart"), + ("deactivating", "stop-sigterm"), + ("deactivating", "final-sigterm"), + ("inactive", "dead"), + ("reloading", "reload"), + ] { + std::fs::write(&state, show("loaded", active, sub))?; + assert!( + !driver.is_permanently_stopped(&observation)?, + "ActiveState={active} SubState={sub} must not read as permanently stopped" + ); + } + + std::fs::write(&state, show("loaded", "failed", "failed"))?; + assert!(driver.is_permanently_stopped(&observation)?, "failed is permanent under Restart=no"); + + // systemd has no such unit: `systemctl show` exits 0 with not-found. + std::fs::write(&state, show("not-found", "inactive", "dead"))?; + assert!(driver.is_permanently_stopped(&observation)?, "a forgotten unit cannot restart"); + + // A systemctl failure with no properties is an error, not "stopped". + std::fs::write(&state, "")?; + std::fs::write(&fail, "")?; + assert!( + driver.is_permanently_stopped(&observation).is_err(), + "a systemctl failure must not be read as a stopped incumbent" + ); + std::fs::remove_file(&fail)?; + + // A failure that still printed properties (systemctl exited non-zero + // after output) is also an error. + std::fs::write(&state, show("loaded", "failed", "failed"))?; + std::fs::write(&fail, "")?; + assert!(driver.is_permanently_stopped(&observation).is_err()); + std::fs::remove_file(&fail)?; + + // The recorded policy is asked before systemd is: any incumbent whose + // observation carries another Restart policy makes the Isolation gate + // error out (pre-fencing abort), whatever systemd says. + std::fs::write(&state, show("loaded", "failed", "failed"))?; + let restarting = audit_workload_observation("always"); + let error = driver + .is_permanently_stopped(&restarting) + .expect_err("Restart=always incumbent must not be judged stopped"); + assert!(format!("{error:#}").contains("Restart=no"), "{error:#}"); + Ok(()) + } + + fn audit_workload_observation(restart_policy: &str) -> WorkloadObservation { + let uid = 61_000u32; + WorkloadObservation { + unit: format!("idunn-{}.service", "a".repeat(64)), + unit_description: "Idunn test".into(), + invocation_id: "inv".into(), + exec_main_start_timestamp_monotonic: 1, + service_type: "exec".into(), + restart_policy: restart_policy.into(), + kill_mode: "mixed".into(), + dynamic_user: true, + systemd_user: format!("u{uid}"), + systemd_group: format!("u{uid}"), + supplementary_groups: String::new(), + capability_bounding_set: String::new(), + ambient_capabilities: String::new(), + private_mounts: true, + private_pids: true, + protect_proc: "invisible".into(), + proc_subset: "all".into(), + no_new_privileges: true, + umask: "0007".into(), + inaccessible_paths: String::new(), + load_credential: String::new(), + main_pid: 4242, + process_start_time: 1, + process_uids: [uid; 4], + process_gids: [uid; 4], + process_groups: vec![uid], + process_cap_inheritable: 0, + process_cap_permitted: 0, + process_cap_effective: 0, + process_cap_bounding: 0, + process_cap_ambient: 0, + process_no_new_privileges: true, + process_namespace_pids: vec![1], + mount_namespace_id: 7, + pid_namespace_id: 8, + executable: PathBuf::from("/opt/test/bin/service"), + executable_device: 1, + executable_inode: 1, + executable_sha256: format!("sha256-{}", "1".repeat(64)), + runtime_instance_id: format!("sha256-{}", "a".repeat(64)), + working_directory: PathBuf::from("/opt/test"), + runtime_bundle: PathBuf::from("/run/test"), + command_line_sha256: format!("sha256-{}", "2".repeat(64)), + environment_names: Vec::new(), + environment_contract_sha256: format!("sha256-{}", "3".repeat(64)), + control_group: "/system.slice/idunn-test.service".into(), + credentials_directory: None, + parent_only_file_descriptors: Vec::new(), + activation_signer_identity_id: "activation".into(), + activation_signer_public_key: vec![1; 32], + service_credentials: Vec::new(), + } + } + #[cfg(unix)] #[test] fn the_published_projection_is_readable_by_a_workload() {