From e9b91c1f50024a7b02679bb36d14cb7abda10c76 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 13 Sep 2026 14:34:10 +0200 Subject: [PATCH] Preserve public trust-anchor parent permissions enroll_service_identity_at and export_service_identity_trust_anchor both routed through prepare_parent, which chmods the target directory to 0700 on unix. That is correct for the private enrollment path but wrong for exporting a public trust anchor: Idunn's provisioning and control-plane code export the public anchor into a directory another service already owns and reads from (Idunn/src/provisioning.rs, Idunn/src/control_plane.rs), and forcibly narrowing that directory's permissions on every export would lock out the legitimate reader instead of protecting anything. Split the helper: ensure_parent only creates the directory, and the renamed prepare_private_parent (used solely by enrollment) also locks it down. Ported from GameCult/cultnet-rs commit 69f2498 ("Preserve public anchor parent permissions"), which fixed the same bug before that standalone repo was superseded by this package. Co-Authored-By: Claude Sonnet 5 --- packages/cultnet-rs/src/service_identity.rs | 45 +++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/cultnet-rs/src/service_identity.rs b/packages/cultnet-rs/src/service_identity.rs index 81b91a8c..6f3e58d8 100644 --- a/packages/cultnet-rs/src/service_identity.rs +++ b/packages/cultnet-rs/src/service_identity.rs @@ -229,7 +229,7 @@ pub fn enroll_service_identity_at( path.display() ); } - prepare_parent(path)?; + prepare_private_parent(path)?; let mut seed = Zeroizing::new([0u8; 32]); rand::rng().fill_bytes(seed.as_mut()); let signing_key = SigningKey::from_bytes(&*seed); @@ -338,7 +338,7 @@ pub fn export_service_identity_trust_anchor( path: &Path, ) -> Result { let anchor = signer.trust_anchor()?; - prepare_parent(path)?; + ensure_parent(path)?; let envelope = CultCacheEnvelope { key: P::TRUST_ANCHOR_KEY.into(), r#type: P::TRUST_ANCHOR_TYPE.into(), @@ -470,14 +470,27 @@ fn private_envelope( }) } -fn prepare_parent(path: &Path) -> Result<()> { +fn ensure_parent(path: &Path) -> Result<()> { let parent = path .parent() .ok_or_else(|| anyhow!("service identity path has no parent"))?; std::fs::create_dir_all(parent)?; + Ok(()) +} + +/// Like [`ensure_parent`], but also locks the directory down to owner-only +/// access. Reserved for paths that hold private key material: a public trust +/// anchor is routinely exported into a directory another service already +/// owns and reads from, and forcing that directory to `0700` would lock the +/// legitimate reader out rather than protect anything. +fn prepare_private_parent(path: &Path) -> Result<()> { + ensure_parent(path)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; + let parent = path + .parent() + .ok_or_else(|| anyhow!("service identity path has no parent"))?; std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?; } Ok(()) @@ -830,6 +843,32 @@ mod tests { Ok(()) } + #[test] + fn exporting_a_public_trust_anchor_does_not_narrow_its_parent_permissions() -> Result<()> { + let temp = tempfile::tempdir()?; + let signer = + enroll_service_identity_at::(&temp.path().join("private.ccmp"))?; + let public_parent = temp.path().join("public"); + std::fs::create_dir(&public_parent)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&public_parent, std::fs::Permissions::from_mode(0o750))?; + } + let output = public_parent.join("public.ccmp"); + export_service_identity_trust_anchor(&signer, &output)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&public_parent)?.permissions().mode() & 0o777, + 0o750, + "exporting a public trust anchor must not reassign its parent directory's permissions" + ); + } + Ok(()) + } + #[test] fn exported_anchor_is_public_only_immutable_and_rejects_identity_substitution() -> Result<()> { let temp = tempfile::tempdir()?;