Audit tests for the admission gating rebuild (c09993c..8448f83)
Soul pass on the five gate changes. Negative checks only: - another incarnation's perfect correlation, another activation of the same Expected, and a foreign target beside our projection are all refused by authentication alone (the deleted identity gate was a subset of it) - the signing fixture refuses at every link: activation/Expected binding, Idunn anchor, provider key, foreign Odin signer, forged signature - a durable receipt replays at its own admitted_at and not at now - is_permanently_stopped is true only for failed or not-found units; every transitional ActiveState reads as running, and a systemctl failure errors - an ignored live-store inventory probe Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
8448f8355a
commit
ffa5926d0d
2 changed files with 408 additions and 0 deletions
|
|
@ -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<cultnet_rs::AuthenticatedOdinRuntimeTopologyCorrelation> {
|
||||
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::<OdinRuntimeTopologyCorrelationPurpose>(&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 {
|
||||
|
|
|
|||
138
src/drivers.rs
138
src/drivers.rs
|
|
@ -6690,6 +6690,144 @@ fn apply_identity(command: &mut Command, identity: Option<ProcessIdentity>) -> 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() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue