use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use super::org::{current_timestamp, OrgError, OrgId, OrgMembershipCert, OrgRevocationBundle};
use super::org_revocation::{
open_regular_nofollow, write_atomic, OrgRevocationError, OrgRevocationState,
OrgRevocationStore, ProvisioningExpectation,
};
use crate::adapter::net::identity::EntityId;
pub const OWNER_MEMBERSHIP_FILE: &str = "owner-membership.json";
pub const OWNER_AUDIENCE_FILE: &str = "owner-audience.key";
pub const REVOCATION_STATE_FILE: &str = "revocation-state.json";
pub const NODE_AUTHORITY_CONFIG_VERSION: u32 = 1;
pub const OWNER_AUDIENCE_KEY_VERSION: u8 = 2;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NodeAuthorityConfig {
pub version: u32,
pub owner_org: OrgId,
pub owner_cert: OrgMembershipCert,
#[serde(default)]
pub verification_skew_secs: u64,
}
impl NodeAuthorityConfig {
pub fn new(owner_cert: OrgMembershipCert, verification_skew_secs: u64) -> Self {
Self {
version: NODE_AUTHORITY_CONFIG_VERSION,
owner_org: owner_cert.org_id,
owner_cert,
verification_skew_secs,
}
}
pub fn verify_binding(&self, local_entity: &EntityId) -> Result<(), OrgAuthorityError> {
if self.version != NODE_AUTHORITY_CONFIG_VERSION {
return Err(OrgAuthorityError::UnsupportedVersion {
path: OWNER_MEMBERSHIP_FILE.to_string(),
found: self.version,
});
}
if self.owner_org != self.owner_cert.org_id {
return Err(OrgAuthorityError::OwnerOrgMismatch {
declared: self.owner_org,
cert_org: self.owner_cert.org_id,
});
}
if self.owner_cert.member != *local_entity {
return Err(OrgAuthorityError::CertNotForThisNode {
cert_member: self.owner_cert.member.clone(),
local_entity: local_entity.clone(),
});
}
self.owner_cert
.verify()
.map_err(OrgAuthorityError::CertInvalid)
}
pub fn self_verify(
&self,
local_entity: &EntityId,
floors: &OrgRevocationState,
) -> Result<(), OrgAuthorityError> {
self.self_verify_at(local_entity, floors, current_timestamp())
}
pub fn self_verify_at(
&self,
local_entity: &EntityId,
floors: &OrgRevocationState,
now_secs: u64,
) -> Result<(), OrgAuthorityError> {
self.verify_binding(local_entity)?;
self.owner_cert
.is_valid_at_with_skew(now_secs, self.verification_skew_secs)
.map_err(OrgAuthorityError::CertInvalid)?;
let floor = floors.floor_for(&self.owner_cert.org_id, &self.owner_cert.member);
if self.owner_cert.generation < floor {
return Err(OrgAuthorityError::CertBelowFloor {
generation: self.owner_cert.generation,
floor,
});
}
Ok(())
}
}
pub struct OwnerAudienceCredential {
pub owner_org: OrgId,
pub audience_handle: [u8; 32],
discovery_key: [u8; 32],
}
const _: fn() = || {
trait AmbiguousIfSerialize<A> {
fn guard() {}
}
impl<T: ?Sized> AmbiguousIfSerialize<()> for T {}
#[allow(dead_code)]
struct IsSerialize;
impl<T: ?Sized + serde::Serialize> AmbiguousIfSerialize<IsSerialize> for T {}
let _ = <OwnerAudienceCredential as AmbiguousIfSerialize<_>>::guard;
};
impl OwnerAudienceCredential {
pub const ENCODED_SIZE: usize = 1 + 32 + 32 + 32;
pub fn generate(owner_org: OrgId) -> Self {
let mut bytes = [0u8; 64];
if let Err(e) = getrandom::fill(&mut bytes) {
eprintln!(
"FATAL: OwnerAudienceCredential getrandom failure ({e:?}); aborting to avoid predictable audience key"
);
std::process::abort();
}
let mut audience_handle = [0u8; 32];
let mut discovery_key = [0u8; 32];
audience_handle.copy_from_slice(&bytes[..32]);
discovery_key.copy_from_slice(&bytes[32..]);
for byte in bytes.iter_mut() {
unsafe { std::ptr::write_volatile(byte, 0) };
}
Self {
owner_org,
audience_handle,
discovery_key,
}
}
pub fn discovery_key(&self) -> &[u8; 32] {
&self.discovery_key
}
pub fn encode_config(&self) -> [u8; Self::ENCODED_SIZE] {
let mut buf = [0u8; Self::ENCODED_SIZE];
buf[0] = OWNER_AUDIENCE_KEY_VERSION;
buf[1..33].copy_from_slice(self.owner_org.as_bytes());
buf[33..65].copy_from_slice(&self.audience_handle);
buf[65..97].copy_from_slice(&self.discovery_key);
buf
}
#[expect(
clippy::unwrap_used,
reason = "length checked to be exactly ENCODED_SIZE above; fixed slices convert infallibly"
)]
pub fn decode_config(bytes: &[u8]) -> Result<Self, OrgAuthorityError> {
if bytes.len() != Self::ENCODED_SIZE {
return Err(OrgAuthorityError::CorruptFile {
path: OWNER_AUDIENCE_FILE.to_string(),
detail: format!(
"expected exactly {} bytes, found {}",
Self::ENCODED_SIZE,
bytes.len()
),
});
}
if bytes[0] != OWNER_AUDIENCE_KEY_VERSION {
return Err(OrgAuthorityError::UnsupportedVersion {
path: OWNER_AUDIENCE_FILE.to_string(),
found: bytes[0] as u32,
});
}
let owner_org: [u8; 32] = bytes[1..33].try_into().unwrap();
Ok(Self {
owner_org: OrgId(owner_org),
audience_handle: bytes[33..65].try_into().unwrap(),
discovery_key: bytes[65..97].try_into().unwrap(),
})
}
}
impl std::fmt::Debug for OwnerAudienceCredential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OwnerAudienceCredential")
.field("audience_handle", &hex::encode(self.audience_handle))
.field("discovery_key", &"[REDACTED]")
.finish()
}
}
impl Drop for OwnerAudienceCredential {
fn drop(&mut self) {
for byte in self.discovery_key.iter_mut() {
unsafe { std::ptr::write_volatile(byte, 0) };
}
}
}
struct ScrubbedBytes(Vec<u8>);
impl ScrubbedBytes {
fn as_slice(&self) -> &[u8] {
&self.0
}
}
impl Drop for ScrubbedBytes {
fn drop(&mut self) {
for byte in self.0.iter_mut() {
unsafe { std::ptr::write_volatile(byte, 0) };
}
}
}
#[derive(Debug)]
pub enum OrgAuthorityError {
MissingFile {
path: String,
},
CorruptFile {
path: String,
detail: String,
},
UnsupportedVersion {
path: String,
found: u32,
},
OwnerOrgMismatch {
declared: OrgId,
cert_org: OrgId,
},
CertNotForThisNode {
cert_member: EntityId,
local_entity: EntityId,
},
CertInvalid(OrgError),
CertBelowFloor {
generation: u32,
floor: u32,
},
AlreadyOwned {
existing: OrgId,
requested: OrgId,
},
ForeignFloorBundle {
bundle_org: OrgId,
owner_org: OrgId,
},
PermissiveAudienceFile {
path: String,
mode: u32,
},
PermissiveAudienceAcl {
path: String,
reason: String,
},
InsecureAuthorityDir {
path: String,
reason: String,
},
NoAuthorityInstalled,
CeremonyRaced {
detail: String,
},
Io {
path: String,
reason: String,
},
Revocation(OrgRevocationError),
SensingFleetRootCollision {
owner_org: OrgId,
},
}
impl std::fmt::Display for OrgAuthorityError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingFile { path } => write!(
f,
"authority file missing: {path}; run `net node adopt` to provision"
),
Self::CorruptFile { path, detail } => {
write!(f, "authority file corrupt: {path} ({detail})")
}
Self::UnsupportedVersion { path, found } => {
write!(f, "authority file {path} has unsupported version {found}")
}
Self::OwnerOrgMismatch { declared, cert_org } => write!(
f,
"owner-membership.json declares org {declared} but its certificate was issued by {cert_org}"
),
Self::CertNotForThisNode {
cert_member,
local_entity,
} => write!(
f,
"owner certificate names {cert_member}, but this node is {local_entity}"
),
Self::CertInvalid(e) => write!(f, "owner certificate invalid: {e}"),
Self::CertBelowFloor { generation, floor } => write!(
f,
"owner certificate generation {generation} is below the persisted revocation floor {floor}"
),
Self::AlreadyOwned {
existing,
requested,
} => write!(
f,
"node already owned by org {existing}; refusing adoption by {requested} \
(one node one owner). To transfer, remove the WHOLE authority directory \
and re-adopt — deleting only the membership file leaves the previous \
org's audience key in place, and the new org's private capabilities \
would be sealed under a key that org does not control"
),
Self::ForeignFloorBundle {
bundle_org,
owner_org,
} => write!(
f,
"floor bundle signed by org {bundle_org} but the candidate owner is \
{owner_org}; the adoption ceremony tracks only the owner root"
),
Self::PermissiveAudienceFile { path, mode } => write!(
f,
"owner audience key {path} has permissive mode {mode:#o} (group/other \
readable); tighten to 0600 — refusing to treat a possibly-disclosed \
audience key as installed"
),
Self::PermissiveAudienceAcl { path, reason } => write!(
f,
"owner audience key {path} has a permissive ACL ({reason}); refusing to \
treat a possibly-disclosed audience key as installed"
),
Self::InsecureAuthorityDir { path, reason } => write!(
f,
"authority directory {path} is not a trusted local boundary: {reason}; \
it must be a directory owned by the current user and not group/other-\
writable (owner-only 0700) — refusing to provision or open authority \
state inside it"
),
Self::NoAuthorityInstalled => write!(
f,
"owner-cert emission requires an installed node authority; run \
`net node adopt` and configure the authority directory first"
),
Self::CeremonyRaced { detail } => write!(
f,
"adoption ceremony raced a concurrent writer ({detail}); the candidate \
authority is NOT installed"
),
Self::Io { path, reason } => write!(f, "authority I/O at {path}: {reason}"),
Self::Revocation(e) => write!(f, "{e}"),
Self::SensingFleetRootCollision { owner_org } => write!(
f,
"explicit sensing fleet root collides with the canonical sensing \
commitment for org {owner_org}; installing this authority would let \
legacy sensing registrations coalesce with organization rows on a \
shared interest key — refusing (clear or reconfigure sensing_owner_root \
before adopting this org)"
),
}
}
}
impl std::error::Error for OrgAuthorityError {}
impl From<OrgRevocationError> for OrgAuthorityError {
fn from(e: OrgRevocationError) -> Self {
Self::Revocation(e)
}
}
pub struct NodeAuthority {
pub config: NodeAuthorityConfig,
pub audience: OwnerAudienceCredential,
pub revocation: Arc<OrgRevocationStore>,
}
impl NodeAuthority {
pub fn adopt(
dir: &Path,
owner_cert: OrgMembershipCert,
local_entity: &EntityId,
skew_secs: u64,
owner_floors: Option<&OrgRevocationBundle>,
) -> Result<Self, OrgAuthorityError> {
let dir_buf = normalize_authority_dir(dir).map_err(|e| OrgAuthorityError::Io {
path: dir.display().to_string(),
reason: format!("normalize authority directory: {e}"),
})?;
let dir: &Path = &dir_buf;
let membership_path = dir.join(OWNER_MEMBERSHIP_FILE);
let audience_path = dir.join(OWNER_AUDIENCE_FILE);
let revocation_path = dir.join(REVOCATION_STATE_FILE);
let owner_org_id = owner_cert.org_id;
ensure_secure_authority_dir(dir)?;
let _ceremony = lock_ceremony(dir)?;
let had_membership = if let Some(existing) = read_optional(&membership_path)? {
let existing: NodeAuthorityConfig = parse_membership(&existing, &membership_path)?;
existing.verify_binding(local_entity).map_err(|e| {
tracing::error!(
"existing membership failed structural verification ({e}); refusing \
re-adoption until the operator repairs or removes it"
);
e
})?;
if existing.owner_org != owner_cert.org_id {
return Err(OrgAuthorityError::AlreadyOwned {
existing: existing.owner_org,
requested: owner_cert.org_id,
});
}
true
} else {
false
};
let have_audience = match read_audience_checked(&audience_path)? {
Some(bytes) => {
let bytes = ScrubbedBytes(bytes);
let credential = OwnerAudienceCredential::decode_config(bytes.as_slice())?;
if credential.owner_org != owner_cert.org_id {
tracing::error!(
"the audience key in this authority directory belongs to a different \
org; refusing to carry it across an ownership change. Remove the whole \
authority directory to re-provision this node under the new org."
);
return Err(OrgAuthorityError::AlreadyOwned {
existing: credential.owner_org,
requested: owner_cert.org_id,
});
}
true
}
None => false,
};
let persisted = OrgRevocationState::load_if_exists(&revocation_path)?
.unwrap_or_else(OrgRevocationState::empty);
if let Some(bundle) = owner_floors {
bundle
.verify()
.map_err(|e| OrgAuthorityError::Revocation(OrgRevocationError::InvalidBundle(e)))?;
if bundle.org_id != owner_cert.org_id {
return Err(OrgAuthorityError::ForeignFloorBundle {
bundle_org: bundle.org_id,
owner_org: owner_cert.org_id,
});
}
}
let mut candidate_floors = persisted;
if let Some(bundle) = owner_floors {
candidate_floors.merge_bundle(bundle);
}
let config = NodeAuthorityConfig::new(owner_cert, skew_secs);
config.self_verify(local_entity, &candidate_floors)?;
let expect = if had_membership || have_audience {
ProvisioningExpectation::MustExist
} else {
ProvisioningExpectation::MayBeFresh
};
let revocation = Arc::new(OrgRevocationStore::init(&revocation_path, expect)?);
if let Some(bundle) = owner_floors {
revocation.apply_bundle(bundle)?;
}
if !have_audience {
let audience = OwnerAudienceCredential::generate(owner_org_id);
let mut raw = audience.encode_config();
let encoded = ScrubbedBytes(raw.to_vec());
for byte in raw.iter_mut() {
unsafe { std::ptr::write_volatile(byte, 0) };
}
write_atomic(&audience_path, encoded.as_slice())?;
}
{
let _state_lock = super::org_revocation::lock_state_file(&revocation_path)
.map_err(OrgAuthorityError::Revocation)?;
let locked_floors = OrgRevocationState::load_if_exists(&revocation_path)
.map_err(OrgAuthorityError::Revocation)?
.unwrap_or_else(OrgRevocationState::empty);
config.self_verify(local_entity, &locked_floors)?;
let membership_bytes =
serde_json::to_vec_pretty(&config).map_err(|e| OrgAuthorityError::Io {
path: membership_path.display().to_string(),
reason: format!("serialize: {e}"),
})?;
write_atomic(&membership_path, &membership_bytes)?;
}
let opened = Self::open(dir, local_entity)?;
if opened.config != config {
return Err(OrgAuthorityError::CeremonyRaced {
detail: format!(
"reopened membership (owner {}) differs from the installed candidate \
(owner {})",
opened.config.owner_org, config.owner_org
),
});
}
Ok(opened)
}
pub fn open(dir: &Path, local_entity: &EntityId) -> Result<Self, OrgAuthorityError> {
let dir_buf = normalize_authority_dir(dir).map_err(|e| OrgAuthorityError::Io {
path: dir.display().to_string(),
reason: format!("normalize authority directory: {e}"),
})?;
let dir: &Path = &dir_buf;
let membership_path = dir.join(OWNER_MEMBERSHIP_FILE);
let audience_path = dir.join(OWNER_AUDIENCE_FILE);
let revocation_path = dir.join(REVOCATION_STATE_FILE);
ensure_secure_authority_dir(dir)?;
let membership_bytes = read_required(&membership_path)?;
let config = parse_membership(&membership_bytes, &membership_path)?;
let audience_bytes =
ScrubbedBytes(read_audience_checked(&audience_path)?.ok_or_else(|| {
let err = OrgAuthorityError::MissingFile {
path: audience_path.display().to_string(),
};
tracing::error!("{err}");
err
})?);
let audience = OwnerAudienceCredential::decode_config(audience_bytes.as_slice())?;
let revocation = Arc::new(OrgRevocationStore::open_existing(&revocation_path)?);
config
.self_verify(local_entity, &revocation.snapshot())
.inspect_err(|e| {
tracing::error!("node authority self-verification failed: {e}");
})?;
Ok(Self {
config,
audience,
revocation,
})
}
pub fn owner_org(&self) -> OrgId {
self.config.owner_org
}
pub fn file_names() -> [&'static str; 3] {
[
OWNER_MEMBERSHIP_FILE,
OWNER_AUDIENCE_FILE,
REVOCATION_STATE_FILE,
]
}
}
impl std::fmt::Debug for NodeAuthority {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NodeAuthority")
.field("owner_org", &self.config.owner_org)
.field("audience", &self.audience)
.field("revocation", &self.revocation)
.finish()
}
}
fn parse_membership(bytes: &[u8], path: &Path) -> Result<NodeAuthorityConfig, OrgAuthorityError> {
let config: NodeAuthorityConfig =
serde_json::from_slice(bytes).map_err(|e| OrgAuthorityError::CorruptFile {
path: path.display().to_string(),
detail: e.to_string(),
})?;
if config.version != NODE_AUTHORITY_CONFIG_VERSION {
return Err(OrgAuthorityError::UnsupportedVersion {
path: path.display().to_string(),
found: config.version,
});
}
Ok(config)
}
fn lock_ceremony(dir: &Path) -> Result<std::fs::File, OrgAuthorityError> {
let lock_path = dir.join("authority.lock");
let io = |e: std::io::Error| OrgAuthorityError::Io {
path: lock_path.display().to_string(),
reason: format!("ceremony lock: {e}"),
};
super::org_revocation::open_lock_file(&lock_path).map_err(io)
}
#[cfg_attr(not(unix), allow(dead_code))]
fn authority_dir_policy_violation(owner_uid: u32, mode: u32, euid: u32) -> Option<String> {
if owner_uid != euid {
return Some(format!(
"owned by uid {owner_uid}, not the current effective user {euid}"
));
}
if mode & 0o022 != 0 {
return Some(format!("group/other-writable (mode {:04o})", mode & 0o777));
}
None
}
#[cfg_attr(not(unix), allow(dead_code))]
fn unix_ancestor_violation(owner_uid: u32, mode: u32, euid: u32) -> Option<String> {
if owner_uid != euid && owner_uid != 0 {
return Some(format!(
"owned by uid {owner_uid}, neither the current user {euid} nor root"
));
}
if mode & 0o022 != 0 && mode & 0o1000 == 0 {
return Some(format!(
"group/other-writable without the sticky bit (mode {:04o})",
mode & 0o7777
));
}
None
}
#[cfg(unix)]
fn validate_unix_ancestor_chain(dir: &Path) -> Result<(), OrgAuthorityError> {
use std::os::unix::fs::MetadataExt;
let io = |e: std::io::Error| OrgAuthorityError::Io {
path: dir.display().to_string(),
reason: format!("authority directory ancestor: {e}"),
};
let mut cursor = dir;
let existing = loop {
match cursor.parent() {
Some(parent) if !parent.as_os_str().is_empty() => {
if parent.exists() {
break parent;
}
cursor = parent;
}
_ => return Ok(()),
}
};
let euid = unsafe { libc::geteuid() };
let real = std::fs::canonicalize(existing).map_err(io)?;
for ancestor in real.ancestors() {
let meta = std::fs::symlink_metadata(ancestor).map_err(io)?;
if let Some(reason) = unix_ancestor_violation(meta.uid(), meta.mode(), euid) {
return Err(OrgAuthorityError::InsecureAuthorityDir {
path: dir.display().to_string(),
reason: format!(
"ancestor {} {reason} — another account could replace the \
authority directory entry through it",
ancestor.display()
),
});
}
}
Ok(())
}
#[cfg(windows)]
fn validate_windows_ancestor_chain(dir: &Path) -> Result<(), OrgAuthorityError> {
let io = |e: std::io::Error| OrgAuthorityError::Io {
path: dir.display().to_string(),
reason: format!("authority directory ancestor: {e}"),
};
let mut cursor = dir;
let existing = loop {
match cursor.parent() {
Some(parent) if !parent.as_os_str().is_empty() => {
if parent.exists() {
break parent;
}
cursor = parent;
}
_ => return Ok(()),
}
};
let user_sid = current_process_sid_string().map_err(|e| OrgAuthorityError::Io {
path: dir.display().to_string(),
reason: format!("resolve current user SID: {e}"),
})?;
const LOCAL_SYSTEM: &str = "S-1-5-18";
const ADMINISTRATORS: &str = "S-1-5-32-544";
const TRUSTED_INSTALLER: &str =
"S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464";
let trusted = |sid: &str| {
sid == user_sid || sid == LOCAL_SYSTEM || sid == ADMINISTRATORS || sid == TRUSTED_INSTALLER
};
let real = std::fs::canonicalize(existing).map_err(io)?;
for ancestor in real.ancestors() {
if ancestor.parent().is_none() {
break;
}
let view = match read_object_security(ancestor) {
Ok(view) => view,
Err(_) => continue,
};
if !trusted(&view.owner_sid) {
return Err(OrgAuthorityError::InsecureAuthorityDir {
path: dir.display().to_string(),
reason: format!(
"ancestor {} is owned by untrusted principal {} — that owner holds \
implicit WRITE_DAC over the component and can remove or replace the \
authority directory's entry through it, whatever the authority \
directory's own ACL says",
ancestor.display(),
view.owner_sid
),
});
}
}
Ok(())
}
#[cfg(windows)]
#[allow(clippy::multiple_unsafe_ops_per_block)]
fn process_user_sid() -> std::io::Result<Vec<u32>> {
type Handle = *mut std::ffi::c_void;
extern "system" {
fn GetCurrentProcess() -> Handle;
fn OpenProcessToken(process: Handle, desired: u32, token: *mut Handle) -> i32;
fn GetTokenInformation(
token: Handle,
class: i32,
info: *mut std::ffi::c_void,
len: u32,
ret_len: *mut u32,
) -> i32;
fn GetLengthSid(sid: *const std::ffi::c_void) -> u32;
fn CopySid(dest_len: u32, dest: *mut std::ffi::c_void, src: *const std::ffi::c_void)
-> i32;
fn CloseHandle(handle: Handle) -> i32;
}
const TOKEN_QUERY: u32 = 0x0008;
const TOKEN_USER: i32 = 1; unsafe {
let mut token: Handle = std::ptr::null_mut();
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 {
return Err(std::io::Error::last_os_error());
}
let mut len: u32 = 0;
GetTokenInformation(token, TOKEN_USER, std::ptr::null_mut(), 0, &mut len);
if len == 0 {
let e = std::io::Error::last_os_error();
CloseHandle(token);
return Err(e);
}
let mut buf = vec![0u8; len as usize];
let ok = GetTokenInformation(token, TOKEN_USER, buf.as_mut_ptr().cast(), len, &mut len);
CloseHandle(token);
if ok == 0 {
return Err(std::io::Error::last_os_error());
}
let psid = (buf.as_ptr() as *const *const std::ffi::c_void).read_unaligned();
let sid_len = GetLengthSid(psid);
if sid_len == 0 {
return Err(std::io::Error::last_os_error());
}
let words = (sid_len as usize).div_ceil(4);
let mut sid = vec![0u32; words];
if CopySid(sid_len, sid.as_mut_ptr().cast(), psid) == 0 {
return Err(std::io::Error::last_os_error());
}
Ok(sid)
}
}
#[cfg(windows)]
#[allow(clippy::multiple_unsafe_ops_per_block)]
fn sid_to_string(psid: *const std::ffi::c_void) -> std::io::Result<String> {
use std::os::windows::ffi::OsStringExt;
extern "system" {
fn ConvertSidToStringSidW(sid: *const std::ffi::c_void, out: *mut *mut u16) -> i32;
fn LocalFree(mem: *mut std::ffi::c_void) -> *mut std::ffi::c_void;
}
unsafe {
let mut out: *mut u16 = std::ptr::null_mut();
if ConvertSidToStringSidW(psid, &mut out) == 0 {
return Err(std::io::Error::last_os_error());
}
let mut n = 0usize;
while *out.add(n) != 0 {
n += 1;
}
let s = std::ffi::OsString::from_wide(std::slice::from_raw_parts(out, n))
.to_string_lossy()
.into_owned();
LocalFree(out.cast());
Ok(s)
}
}
#[cfg(windows)]
fn current_process_sid_string() -> std::io::Result<String> {
let sid = process_user_sid()?;
sid_to_string(sid.as_ptr().cast())
}
#[cfg(windows)]
#[allow(clippy::multiple_unsafe_ops_per_block)]
fn create_dir_with_owner_only_dacl(path: &Path, sid: &[u32]) -> std::io::Result<()> {
use std::os::windows::ffi::OsStrExt;
type Pv = *mut std::ffi::c_void;
#[repr(C)]
struct SecurityAttributes {
n_length: u32,
lp_security_descriptor: Pv,
b_inherit_handle: i32,
}
#[repr(C)]
struct SecurityDescriptor {
revision: u8,
sbz1: u8,
control: u16,
owner: Pv,
group: Pv,
sacl: Pv,
dacl: Pv,
}
extern "system" {
fn InitializeAcl(acl: Pv, len: u32, revision: u32) -> i32;
fn AddAccessAllowedAceEx(acl: Pv, revision: u32, flags: u32, mask: u32, sid: Pv) -> i32;
fn InitializeSecurityDescriptor(sd: Pv, revision: u32) -> i32;
fn SetSecurityDescriptorDacl(sd: Pv, present: i32, dacl: Pv, defaulted: i32) -> i32;
fn SetSecurityDescriptorControl(sd: Pv, mask: u16, bits: u16) -> i32;
fn CreateDirectoryW(path: *const u16, sa: *const SecurityAttributes) -> i32;
}
const ACL_REVISION: u32 = 2;
const SD_REVISION: u32 = 1;
const OBJECT_INHERIT_ACE: u32 = 0x1;
const CONTAINER_INHERIT_ACE: u32 = 0x2;
const FILE_ALL_ACCESS: u32 = 0x001F_01FF;
const SE_DACL_PROTECTED: u16 = 0x1000;
let mut wide: Vec<u16> = path.as_os_str().encode_wide().collect();
wide.push(0);
let mut acl_buf = [0u32; 128];
let mut sd = SecurityDescriptor {
revision: 0,
sbz1: 0,
control: 0,
owner: std::ptr::null_mut(),
group: std::ptr::null_mut(),
sacl: std::ptr::null_mut(),
dacl: std::ptr::null_mut(),
};
unsafe {
let acl: Pv = acl_buf.as_mut_ptr().cast();
let sid_ptr = sid.as_ptr() as Pv;
let sd_ptr: Pv = (&mut sd as *mut SecurityDescriptor).cast();
if InitializeAcl(acl, (acl_buf.len() * 4) as u32, ACL_REVISION) == 0 {
return Err(std::io::Error::last_os_error());
}
if AddAccessAllowedAceEx(
acl,
ACL_REVISION,
OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE,
FILE_ALL_ACCESS,
sid_ptr,
) == 0
{
return Err(std::io::Error::last_os_error());
}
if InitializeSecurityDescriptor(sd_ptr, SD_REVISION) == 0 {
return Err(std::io::Error::last_os_error());
}
if SetSecurityDescriptorDacl(sd_ptr, 1, acl, 0) == 0 {
return Err(std::io::Error::last_os_error());
}
if SetSecurityDescriptorControl(sd_ptr, SE_DACL_PROTECTED, SE_DACL_PROTECTED) == 0 {
return Err(std::io::Error::last_os_error());
}
let sa = SecurityAttributes {
n_length: std::mem::size_of::<SecurityAttributes>() as u32,
lp_security_descriptor: sd_ptr,
b_inherit_handle: 0,
};
if CreateDirectoryW(wide.as_ptr(), &sa) == 0 {
return Err(std::io::Error::last_os_error());
}
}
Ok(())
}
#[cfg(windows)]
#[allow(clippy::multiple_unsafe_ops_per_block)]
fn protect_existing_dir_dacl(path: &Path, sid: &[u32]) -> std::io::Result<()> {
use std::os::windows::ffi::OsStrExt;
type Pv = *mut std::ffi::c_void;
extern "system" {
fn InitializeAcl(acl: Pv, len: u32, revision: u32) -> i32;
fn AddAccessAllowedAceEx(acl: Pv, revision: u32, flags: u32, mask: u32, sid: Pv) -> i32;
fn SetNamedSecurityInfoW(
object_name: *mut u16,
object_type: u32,
security_info: u32,
owner: Pv,
group: Pv,
dacl: Pv,
sacl: Pv,
) -> u32;
}
const ACL_REVISION: u32 = 2;
const OBJECT_INHERIT_ACE: u32 = 0x1;
const CONTAINER_INHERIT_ACE: u32 = 0x2;
const FILE_ALL_ACCESS: u32 = 0x001F_01FF;
const SE_FILE_OBJECT: u32 = 1;
const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004;
const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x8000_0000;
const ERROR_SUCCESS: u32 = 0;
let mut wide: Vec<u16> = path.as_os_str().encode_wide().collect();
wide.push(0);
let mut acl_buf = [0u32; 128];
unsafe {
let acl: Pv = acl_buf.as_mut_ptr().cast();
let sid_ptr = sid.as_ptr() as Pv;
if InitializeAcl(acl, (acl_buf.len() * 4) as u32, ACL_REVISION) == 0 {
return Err(std::io::Error::last_os_error());
}
if AddAccessAllowedAceEx(
acl,
ACL_REVISION,
OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE,
FILE_ALL_ACCESS,
sid_ptr,
) == 0
{
return Err(std::io::Error::last_os_error());
}
let rc = SetNamedSecurityInfoW(
wide.as_mut_ptr(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
std::ptr::null_mut(),
std::ptr::null_mut(),
acl,
std::ptr::null_mut(),
);
if rc != ERROR_SUCCESS {
return Err(std::io::Error::from_raw_os_error(rc as i32));
}
}
Ok(())
}
#[cfg(windows)]
fn create_missing_components_owner_only(dir: &Path, sid: &[u32]) -> std::io::Result<()> {
let mut missing: Vec<&Path> = Vec::new();
let mut cursor: &Path = dir;
loop {
if cursor.exists() {
break;
}
match cursor.parent() {
Some(parent) if !parent.as_os_str().is_empty() => {
missing.push(cursor);
cursor = parent;
}
_ => break,
}
}
for component in missing.iter().rev() {
create_dir_with_owner_only_dacl(component, sid)?;
}
Ok(())
}
#[cfg(windows)]
#[derive(Debug, Clone)]
struct AceInfo {
sid: String,
mask: u32,
ace_type: u8,
#[allow(dead_code)]
flags: u8,
}
#[cfg(windows)]
#[derive(Debug)]
struct DaclView {
owner_sid: String,
protected: bool,
null_dacl: bool,
aces: Vec<AceInfo>,
}
#[cfg(windows)]
const NON_SIMPLE_ACE_SID: &str = "<non-simple-ace>";
#[cfg(windows)]
const WRITE_MASK: u32 = 0x0000_0002 | 0x0000_0004 | 0x0000_0010 | 0x0000_0040 | 0x0000_0100 | 0x0001_0000 | 0x0004_0000 | 0x0008_0000 | 0x1000_0000 | 0x4000_0000;
#[cfg(windows)]
#[allow(clippy::multiple_unsafe_ops_per_block)]
fn read_object_security(path: &Path) -> std::io::Result<DaclView> {
use std::os::windows::ffi::OsStrExt;
type Pv = *mut std::ffi::c_void;
extern "system" {
fn GetFileSecurityW(name: *const u16, info: u32, sd: Pv, len: u32, needed: *mut u32)
-> i32;
}
const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001;
const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004;
const INFO: u32 = OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION;
let mut wide: Vec<u16> = path.as_os_str().encode_wide().collect();
wide.push(0);
unsafe {
let mut needed: u32 = 0;
GetFileSecurityW(wide.as_ptr(), INFO, std::ptr::null_mut(), 0, &mut needed);
if needed == 0 {
return Err(std::io::Error::last_os_error());
}
let mut sd_buf = vec![0u32; (needed as usize).div_ceil(4)];
let sd: Pv = sd_buf.as_mut_ptr().cast();
if GetFileSecurityW(
wide.as_ptr(),
INFO,
sd,
(sd_buf.len() * 4) as u32,
&mut needed,
) == 0
{
return Err(std::io::Error::last_os_error());
}
dacl_view_from_descriptor(sd)
}
}
#[cfg(windows)]
unsafe fn dacl_view_from_descriptor(sd: *mut std::ffi::c_void) -> std::io::Result<DaclView> {
type Pv = *mut std::ffi::c_void;
extern "system" {
fn GetSecurityDescriptorOwner(sd: Pv, owner: *mut Pv, defaulted: *mut i32) -> i32;
fn GetSecurityDescriptorControl(sd: Pv, control: *mut u16, revision: *mut u32) -> i32;
fn GetSecurityDescriptorDacl(
sd: Pv,
present: *mut i32,
dacl: *mut Pv,
defaulted: *mut i32,
) -> i32;
fn GetAce(acl: Pv, index: u32, ace: *mut Pv) -> i32;
}
const SE_DACL_PROTECTED: u16 = 0x1000;
let mut owner: Pv = std::ptr::null_mut();
let mut defaulted: i32 = 0;
if GetSecurityDescriptorOwner(sd, &mut owner, &mut defaulted) == 0 || owner.is_null() {
return Err(std::io::Error::last_os_error());
}
let owner_sid = sid_to_string(owner)?;
let mut control: u16 = 0;
let mut revision: u32 = 0;
if GetSecurityDescriptorControl(sd, &mut control, &mut revision) == 0 {
return Err(std::io::Error::last_os_error());
}
let protected = control & SE_DACL_PROTECTED != 0;
let mut present: i32 = 0;
let mut dacl: Pv = std::ptr::null_mut();
let mut dacl_defaulted: i32 = 0;
if GetSecurityDescriptorDacl(sd, &mut present, &mut dacl, &mut dacl_defaulted) == 0 {
return Err(std::io::Error::last_os_error());
}
if present == 0 || dacl.is_null() {
return Ok(DaclView {
owner_sid,
protected,
null_dacl: true,
aces: Vec::new(),
});
}
let ace_count = (dacl.cast::<u8>().add(4) as *const u16).read_unaligned();
let mut aces = Vec::with_capacity(ace_count as usize);
for i in 0..u32::from(ace_count) {
let mut ace: Pv = std::ptr::null_mut();
if GetAce(dacl, i, &mut ace) == 0 {
return Err(std::io::Error::last_os_error());
}
let base = ace.cast::<u8>();
let ace_type = *base;
let flags = *base.add(1);
let mask = (base.add(4) as *const u32).read_unaligned();
let sid = if ace_type == 0 || ace_type == 1 {
sid_to_string(base.add(8).cast())?
} else {
String::from(NON_SIMPLE_ACE_SID)
};
aces.push(AceInfo {
sid,
mask,
ace_type,
flags,
});
}
Ok(DaclView {
owner_sid,
protected,
null_dacl: false,
aces,
})
}
#[cfg(windows)]
#[allow(clippy::multiple_unsafe_ops_per_block)]
fn read_object_security_handle(
handle: std::os::windows::io::RawHandle,
) -> std::io::Result<DaclView> {
type Pv = *mut std::ffi::c_void;
extern "system" {
fn GetSecurityInfo(
handle: Pv,
object_type: u32,
security_info: u32,
owner: *mut Pv,
group: *mut Pv,
dacl: *mut Pv,
sacl: *mut Pv,
sd: *mut Pv,
) -> u32;
fn LocalFree(mem: Pv) -> Pv;
}
const SE_FILE_OBJECT: u32 = 1;
const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001;
const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004;
const INFO: u32 = OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION;
unsafe {
let mut psd: Pv = std::ptr::null_mut();
let mut owner: Pv = std::ptr::null_mut();
let mut dacl: Pv = std::ptr::null_mut();
let rc = GetSecurityInfo(
handle,
SE_FILE_OBJECT,
INFO,
&mut owner,
std::ptr::null_mut(),
&mut dacl,
std::ptr::null_mut(),
&mut psd,
);
if rc != 0 {
return Err(std::io::Error::from_raw_os_error(rc as i32));
}
if psd.is_null() {
return Err(std::io::Error::other(
"GetSecurityInfo returned a NULL security descriptor",
));
}
let view = dacl_view_from_descriptor(psd);
LocalFree(psd);
view
}
}
#[cfg(windows)]
fn validate_audience_file_acl_handle(
file: &std::fs::File,
path: &Path,
) -> Result<(), OrgAuthorityError> {
use std::os::windows::io::AsRawHandle;
let view =
read_object_security_handle(file.as_raw_handle()).map_err(|e| OrgAuthorityError::Io {
path: path.display().to_string(),
reason: format!("read audience key security descriptor (open handle): {e}"),
})?;
let user_sid = current_process_sid_string().map_err(|e| OrgAuthorityError::Io {
path: path.display().to_string(),
reason: format!("resolve current user SID: {e}"),
})?;
validate_audience_acl_view(&view, &user_sid, path)
}
#[cfg(unix)]
fn create_missing_components_0700(dir: &Path) -> std::io::Result<()> {
use std::os::unix::fs::DirBuilderExt;
let mut missing: Vec<&Path> = Vec::new();
let mut cursor: &Path = dir;
loop {
if cursor.exists() {
break;
}
missing.push(cursor);
match cursor.parent() {
Some(parent) if !parent.as_os_str().is_empty() => cursor = parent,
_ => break,
}
}
for component in missing.iter().rev() {
std::fs::DirBuilder::new().mode(0o700).create(component)?;
}
Ok(())
}
fn normalize_authority_dir(dir: &Path) -> std::io::Result<PathBuf> {
let base = if dir.is_absolute() {
dir.to_path_buf()
} else {
std::env::current_dir()?.join(dir)
};
Ok(base.components().collect())
}
fn ensure_secure_authority_dir(dir: &Path) -> Result<(), OrgAuthorityError> {
let io = |e: std::io::Error| OrgAuthorityError::Io {
path: dir.display().to_string(),
reason: format!("authority directory: {e}"),
};
#[cfg(unix)]
{
use std::os::unix::fs::{MetadataExt, PermissionsExt};
validate_unix_ancestor_chain(dir)?;
match std::fs::symlink_metadata(dir) {
Ok(meta) => {
if !meta.file_type().is_dir() {
return Err(OrgAuthorityError::InsecureAuthorityDir {
path: dir.display().to_string(),
reason: "path exists but is not a directory".to_string(),
});
}
let euid = unsafe { libc::geteuid() };
if let Some(reason) = authority_dir_policy_violation(meta.uid(), meta.mode(), euid)
{
return Err(OrgAuthorityError::InsecureAuthorityDir {
path: dir.display().to_string(),
reason,
});
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
create_missing_components_0700(dir).map_err(io)?;
validate_unix_ancestor_chain(dir)?;
}
Err(e) => return Err(io(e)),
}
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).map_err(io)?;
Ok(())
}
#[cfg(not(unix))]
{
match std::fs::symlink_metadata(dir) {
Ok(meta) => {
if !meta.file_type().is_dir() {
return Err(OrgAuthorityError::InsecureAuthorityDir {
path: dir.display().to_string(),
reason: "path exists but is not a directory".to_string(),
});
}
#[cfg(windows)]
{
validate_windows_ancestor_chain(dir)?;
validate_existing_dir_dacl(dir)?;
}
#[cfg(not(windows))]
tracing::debug!(
path = %dir.display(),
"authority directory exists; binary ACL validation is \
unavailable on this platform",
);
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
#[cfg(windows)]
{
validate_windows_ancestor_chain(dir)?;
let sid = process_user_sid().map_err(io)?;
create_missing_components_owner_only(dir, &sid).map_err(io)?;
validate_windows_ancestor_chain(dir)?;
}
#[cfg(not(windows))]
{
if let Some(parent) = dir.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(io)?;
}
}
std::fs::create_dir(dir).map_err(io)?;
}
}
Err(e) => return Err(io(e)),
}
Ok(())
}
}
#[cfg(windows)]
fn validate_existing_dir_dacl(dir: &Path) -> Result<(), OrgAuthorityError> {
let view = read_object_security(dir).map_err(|e| OrgAuthorityError::Io {
path: dir.display().to_string(),
reason: format!("read authority directory security descriptor: {e}"),
})?;
let user_sid = current_process_sid_string().map_err(|e| OrgAuthorityError::Io {
path: dir.display().to_string(),
reason: format!("resolve current user SID: {e}"),
})?;
if !view.protected {
validate_dacl_rules(&view, &user_sid, dir)?;
let sid = process_user_sid().map_err(|e| OrgAuthorityError::Io {
path: dir.display().to_string(),
reason: format!("resolve current user SID: {e}"),
})?;
protect_existing_dir_dacl(dir, &sid).map_err(|e| OrgAuthorityError::Io {
path: dir.display().to_string(),
reason: format!("sever DACL inheritance on the authority directory: {e}"),
})?;
tracing::info!(
path = %dir.display(),
"severed DACL inheritance on the pre-existing authority directory so \
an ancestor cannot later propagate access onto the authority files",
);
let view = read_object_security(dir).map_err(|e| OrgAuthorityError::Io {
path: dir.display().to_string(),
reason: format!("re-read security descriptor after severing inheritance: {e}"),
})?;
return validate_dacl_view(&view, &user_sid, dir);
}
validate_dacl_view(&view, &user_sid, dir)
}
#[cfg(windows)]
fn validate_dacl_view(
view: &DaclView,
user_sid: &str,
dir: &Path,
) -> Result<(), OrgAuthorityError> {
validate_dacl_rules(view, user_sid, dir)?;
if !view.protected {
return Err(OrgAuthorityError::InsecureAuthorityDir {
path: dir.display().to_string(),
reason: format!(
"authority directory does not have a PROTECTED DACL (SE_DACL_PROTECTED is \
unset), so it still inherits from its parents — anyone who can write an \
inheritable ace on ANY ancestor can propagate access onto \
{OWNER_AUDIENCE_FILE} after this validation passes, without needing any \
permission on the authority directory itself. Sever inheritance on this \
directory (`icacls <dir> /inheritance:r`) or let `net node adopt` create it"
),
});
}
Ok(())
}
#[cfg(windows)]
fn validate_dacl_rules(
view: &DaclView,
user_sid: &str,
dir: &Path,
) -> Result<(), OrgAuthorityError> {
if view.null_dacl {
return Err(OrgAuthorityError::InsecureAuthorityDir {
path: dir.display().to_string(),
reason: "authority directory has a NULL/absent DACL (grants everyone full access)"
.to_string(),
});
}
const LOCAL_SYSTEM: &str = "S-1-5-18";
const ADMINISTRATORS: &str = "S-1-5-32-544";
let trusted = |sid: &str| sid == user_sid || sid == LOCAL_SYSTEM || sid == ADMINISTRATORS;
if !trusted(&view.owner_sid) {
return Err(OrgAuthorityError::InsecureAuthorityDir {
path: dir.display().to_string(),
reason: format!(
"authority directory is owned by untrusted principal {} — the owner holds \
implicit WRITE_DAC and can re-grant itself access at any time, so a \
restrictive DACL is not sufficient. Only the current user, SYSTEM, and \
Administrators are trusted owners",
view.owner_sid
),
});
}
for ace in &view.aces {
const ACCESS_DENIED: u8 = 1;
const ACCESS_DENIED_OBJECT: u8 = 6;
const ACCESS_DENIED_CALLBACK: u8 = 10;
const ACCESS_DENIED_CALLBACK_OBJECT: u8 = 12;
if matches!(
ace.ace_type,
ACCESS_DENIED
| ACCESS_DENIED_OBJECT
| ACCESS_DENIED_CALLBACK
| ACCESS_DENIED_CALLBACK_OBJECT
) {
continue;
}
const OBJECT_INHERIT_ACE: u8 = 0x01;
const CONTAINER_INHERIT_ACE: u8 = 0x02;
if ace.flags & (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE) != 0 && !trusted(&ace.sid) {
return Err(OrgAuthorityError::InsecureAuthorityDir {
path: dir.display().to_string(),
reason: format!(
"authority directory carries an INHERITABLE ace for untrusted principal \
{} (ace type {}, mask {:#010x}, flags {:#04x}) — authority files inherit \
this directory's ACL on Windows, so it would propagate onto \
{OWNER_AUDIENCE_FILE}. Only the owner, SYSTEM, and Administrators may \
hold an inheritable ace here, read-only or not",
ace.sid, ace.ace_type, ace.mask, ace.flags
),
});
}
if ace.mask & WRITE_MASK == 0 {
continue;
}
if !trusted(&ace.sid) {
return Err(OrgAuthorityError::InsecureAuthorityDir {
path: dir.display().to_string(),
reason: format!(
"authority directory grants write access to untrusted principal {} \
(ace type {}, mask {:#010x}) — only the owner, SYSTEM, and \
Administrators are trusted",
ace.sid, ace.ace_type, ace.mask
),
});
}
}
Ok(())
}
fn read_audience_checked(path: &Path) -> Result<Option<Vec<u8>>, OrgAuthorityError> {
use std::io::Read;
let file = match super::org_revocation::open_regular_nofollow(path) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(OrgAuthorityError::Io {
path: path.display().to_string(),
reason: e.to_string(),
})
}
};
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let meta = file.metadata().map_err(|e| OrgAuthorityError::Io {
path: path.display().to_string(),
reason: e.to_string(),
})?;
let mode = meta.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
return Err(OrgAuthorityError::PermissiveAudienceFile {
path: path.display().to_string(),
mode,
});
}
}
#[cfg(windows)]
{
validate_audience_file_acl(path)?;
}
#[cfg(not(any(unix, windows)))]
{
eprintln!(
"warning: audience-key permission gate is a no-op on this platform; \
ACLs on {} are not validated — manage them out-of-band.",
path.display()
);
}
let mut file = file;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)
.map_err(|e| OrgAuthorityError::Io {
path: path.display().to_string(),
reason: e.to_string(),
})?;
Ok(Some(bytes))
}
pub fn load_grant_audience_secret(
path: &Path,
) -> Result<super::org_grant::OrgAudienceSecret, OrgAuthorityError> {
use std::io::Read;
let encoded_size = super::org_grant::OrgAudienceSecret::ENCODED_SIZE;
#[cfg(unix)]
{
let parent = match path.parent() {
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
_ => std::env::current_dir().map_err(|e| OrgAuthorityError::Io {
path: path.display().to_string(),
reason: format!("resolve audience secret parent: {e}"),
})?,
};
validate_unix_ancestor_chain(&parent)?;
}
let mut file = open_regular_nofollow(path).map_err(|e| OrgAuthorityError::Io {
path: path.display().to_string(),
reason: format!("open audience secret: {e}"),
})?;
let meta = file.metadata().map_err(|e| OrgAuthorityError::Io {
path: path.display().to_string(),
reason: format!("stat audience secret: {e}"),
})?;
if !meta.is_file() {
return Err(OrgAuthorityError::Io {
path: path.display().to_string(),
reason: "audience secret is not a regular file".to_string(),
});
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = meta.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
return Err(OrgAuthorityError::PermissiveAudienceFile {
path: path.display().to_string(),
mode,
});
}
}
#[cfg(windows)]
{
validate_audience_file_acl_handle(&file, path)?;
}
let mut buf = ScrubbedBytes(vec![0u8; encoded_size]);
file.read_exact(&mut buf.0)
.map_err(|e| OrgAuthorityError::Io {
path: path.display().to_string(),
reason: format!("audience secret is shorter than {encoded_size} bytes: {e}"),
})?;
let mut trailing = [0u8; 1];
match file.read(&mut trailing) {
Ok(0) => {}
Ok(_) => {
return Err(OrgAuthorityError::CorruptFile {
path: path.display().to_string(),
detail: format!("audience secret has trailing bytes after {encoded_size}"),
})
}
Err(e) => {
return Err(OrgAuthorityError::Io {
path: path.display().to_string(),
reason: format!("audience secret trailing-byte probe: {e}"),
})
}
}
super::org_grant::OrgAudienceSecret::decode_config(buf.as_slice()).map_err(|_| {
OrgAuthorityError::CorruptFile {
path: path.display().to_string(),
detail: "audience secret failed to decode".to_string(),
}
})
}
#[cfg(windows)]
fn validate_audience_file_acl(path: &Path) -> Result<(), OrgAuthorityError> {
let view = read_object_security(path).map_err(|e| OrgAuthorityError::Io {
path: path.display().to_string(),
reason: format!("read audience key security descriptor: {e}"),
})?;
let user_sid = current_process_sid_string().map_err(|e| OrgAuthorityError::Io {
path: path.display().to_string(),
reason: format!("resolve current user SID: {e}"),
})?;
validate_audience_acl_view(&view, &user_sid, path)
}
#[cfg(windows)]
fn validate_audience_acl_view(
view: &DaclView,
user_sid: &str,
path: &Path,
) -> Result<(), OrgAuthorityError> {
if view.null_dacl {
return Err(OrgAuthorityError::PermissiveAudienceAcl {
path: path.display().to_string(),
reason: "NULL/absent DACL grants everyone full access".to_string(),
});
}
const LOCAL_SYSTEM: &str = "S-1-5-18";
const ADMINISTRATORS: &str = "S-1-5-32-544";
let trusted = |sid: &str| sid == user_sid || sid == LOCAL_SYSTEM || sid == ADMINISTRATORS;
if !trusted(&view.owner_sid) {
return Err(OrgAuthorityError::PermissiveAudienceAcl {
path: path.display().to_string(),
reason: format!(
"owned by untrusted principal {} — the owner holds implicit WRITE_DAC and \
can re-grant itself read access at any time",
view.owner_sid
),
});
}
for ace in &view.aces {
const ACCESS_DENIED: u8 = 1;
const ACCESS_DENIED_OBJECT: u8 = 6;
const ACCESS_DENIED_CALLBACK: u8 = 10;
const ACCESS_DENIED_CALLBACK_OBJECT: u8 = 12;
if matches!(
ace.ace_type,
ACCESS_DENIED
| ACCESS_DENIED_OBJECT
| ACCESS_DENIED_CALLBACK
| ACCESS_DENIED_CALLBACK_OBJECT
) {
continue;
}
if !trusted(&ace.sid) {
return Err(OrgAuthorityError::PermissiveAudienceAcl {
path: path.display().to_string(),
reason: format!(
"grants access to untrusted principal {} (ace type {}, mask {:#010x}); \
the raw owner discovery key must be readable only by its owner, \
SYSTEM, and Administrators",
ace.sid, ace.ace_type, ace.mask
),
});
}
}
Ok(())
}
fn read_optional(path: &Path) -> Result<Option<Vec<u8>>, OrgAuthorityError> {
match super::org_revocation::read_regular_nofollow(path) {
Ok(bytes) => Ok(Some(bytes)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(OrgAuthorityError::Io {
path: path.display().to_string(),
reason: e.to_string(),
}),
}
}
fn read_required(path: &Path) -> Result<Vec<u8>, OrgAuthorityError> {
read_optional(path)?.ok_or_else(|| {
let err = OrgAuthorityError::MissingFile {
path: path.display().to_string(),
};
tracing::error!("{err}");
err
})
}
pub fn authority_dir(config_root: &Path) -> PathBuf {
config_root.join("authority")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adapter::net::behavior::org::{OrgKeypair, OrgRevocationBundle};
use crate::adapter::net::identity::EntityKeypair;
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicUsize, Ordering};
static TEST_DIR_SEQ: AtomicUsize = AtomicUsize::new(0);
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let dir = std::env::temp_dir().join(format!(
"net-org-authority-{}-{}",
std::process::id(),
TEST_DIR_SEQ.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).expect("create scratch dir");
Self(dir)
}
fn dir(&self) -> &Path {
&self.0
}
}
#[cfg(unix)]
fn run_in_isolated_child(test_path: &str, body: impl FnOnce()) {
const ISOLATED_CHILD_ENV: &str = "NET_AUTHORITY_ISOLATED_CHILD";
if std::env::var_os(ISOLATED_CHILD_ENV).is_some() {
body();
return;
}
let exe = std::env::current_exe().expect("locate the running test binary");
let out = std::process::Command::new(exe)
.args(["--exact", "--nocapture", "--test-threads=1", test_path])
.env(ISOLATED_CHILD_ENV, "1")
.output()
.expect("spawn isolated child test process");
assert!(
out.status.success(),
"isolated child `{test_path}` failed (exit {:?})\n\
--- child stdout ---\n{}\n--- child stderr ---\n{}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
}
fn org() -> OrgKeypair {
OrgKeypair::from_bytes([0x42u8; 32])
}
fn node_identity() -> EntityKeypair {
EntityKeypair::from_bytes([0x24u8; 32])
}
fn cert_for(kp: &EntityKeypair, generation: u32) -> OrgMembershipCert {
OrgMembershipCert::try_issue(&org(), kp.entity_id().clone(), generation, 3600)
.expect("issue")
}
#[test]
fn adopt_provisions_all_three_files_and_open_succeeds() {
let scratch = Scratch::new();
let kp = node_identity();
let authority =
NodeAuthority::adopt(scratch.dir(), cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect("adopt");
assert_eq!(authority.owner_org(), org().org_id());
for name in NodeAuthority::file_names() {
assert!(
scratch.dir().join(name).exists(),
"{name} must exist after adopt"
);
}
let opened = NodeAuthority::open(scratch.dir(), kp.entity_id()).expect("open");
assert_eq!(opened.config, authority.config);
assert_eq!(
opened.audience.audience_handle,
authority.audience.audience_handle
);
assert_eq!(
opened.audience.discovery_key(),
authority.audience.discovery_key()
);
}
#[cfg(unix)]
#[test]
fn non_regular_ceremony_lock_is_refused() {
let scratch = Scratch::new();
let kp = node_identity();
let status = std::process::Command::new("mkfifo")
.arg(scratch.dir().join("authority.lock"))
.status()
.expect("run mkfifo");
assert!(status.success(), "mkfifo failed");
let err = NodeAuthority::adopt(scratch.dir(), cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect_err("FIFO ceremony lock must refuse");
assert!(matches!(err, OrgAuthorityError::Io { .. }), "got: {err}");
assert!(
!scratch.dir().join(OWNER_MEMBERSHIP_FILE).exists(),
"refused ceremony must not publish membership"
);
}
#[test]
fn interrupted_adoption_is_fail_closed_and_resumable() {
let scratch = Scratch::new();
let kp = node_identity();
let first = NodeAuthority::adopt(scratch.dir(), cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect("adopt");
let mut floors = BTreeMap::new();
floors.insert(EntityId::from_bytes([9u8; 32]), 7u32);
let bundle = OrgRevocationBundle::try_issue(&org(), &floors).expect("issue");
first.revocation.apply_bundle(&bundle).expect("apply");
let handle_before = first.audience.audience_handle;
drop(first);
std::fs::remove_file(scratch.dir().join(OWNER_MEMBERSHIP_FILE)).expect("interrupt");
let err = NodeAuthority::open(scratch.dir(), kp.entity_id())
.expect_err("partial scaffold must refuse startup");
assert!(
matches!(err, OrgAuthorityError::MissingFile { .. }),
"got: {err}"
);
let resumed =
NodeAuthority::adopt(scratch.dir(), cert_for(&kp, 2), kp.entity_id(), 0, None)
.expect("re-run completes the ceremony");
assert_eq!(resumed.audience.audience_handle, handle_before);
assert_eq!(
resumed
.revocation
.floor_for(&org().org_id(), &EntityId::from_bytes([9u8; 32])),
7,
"monotone floor state survives the interruption"
);
NodeAuthority::open(scratch.dir(), kp.entity_id()).expect("startup succeeds after resume");
}
#[cfg(unix)]
#[test]
fn audience_key_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let scratch = Scratch::new();
let kp = node_identity();
NodeAuthority::adopt(scratch.dir(), cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect("adopt");
let mode = std::fs::metadata(scratch.dir().join(OWNER_AUDIENCE_FILE))
.expect("metadata")
.permissions()
.mode();
assert_eq!(
mode & 0o077,
0,
"owner-audience.key must not be group/other readable (mode {mode:o})"
);
}
#[test]
fn authority_dir_policy_rejects_wrong_owner_and_world_writable() {
assert_eq!(authority_dir_policy_violation(1000, 0o700, 1000), None);
assert_eq!(authority_dir_policy_violation(1000, 0o755, 1000), None);
assert!(authority_dir_policy_violation(0, 0o700, 1000)
.unwrap()
.contains("uid 0"));
assert!(authority_dir_policy_violation(1000, 0o770, 1000)
.unwrap()
.contains("group/other-writable"));
assert!(authority_dir_policy_violation(1000, 0o707, 1000)
.unwrap()
.contains("group/other-writable"));
}
#[test]
fn unix_ancestor_violation_covers_ownership_and_sticky() {
assert_eq!(unix_ancestor_violation(1000, 0o755, 1000), None);
assert_eq!(unix_ancestor_violation(0, 0o1777, 1000), None);
assert_eq!(unix_ancestor_violation(1000, 0o1777, 1000), None);
assert!(unix_ancestor_violation(1234, 0o755, 1000)
.unwrap()
.contains("uid 1234"));
assert!(unix_ancestor_violation(1234, 0o1777, 1000)
.unwrap()
.contains("uid 1234"));
assert!(unix_ancestor_violation(1000, 0o0777, 1000)
.unwrap()
.contains("sticky"));
}
#[cfg(unix)]
#[test]
fn adopt_creates_owner_only_authority_dir_and_files() {
use std::os::unix::fs::PermissionsExt;
let scratch = Scratch::new();
let kp = node_identity();
let authority_dir = scratch.dir().join("authority");
NodeAuthority::adopt(&authority_dir, cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect("adopt");
let dir_mode = std::fs::metadata(&authority_dir)
.expect("dir metadata")
.permissions()
.mode();
assert_eq!(
dir_mode & 0o777,
0o700,
"authority dir must be owner-only 0700 (mode {dir_mode:o})",
);
for name in [
OWNER_MEMBERSHIP_FILE,
REVOCATION_STATE_FILE,
OWNER_AUDIENCE_FILE,
] {
let mode = std::fs::metadata(authority_dir.join(name))
.expect("file metadata")
.permissions()
.mode();
assert_eq!(mode & 0o077, 0, "{name} must be owner-only (mode {mode:o})");
}
}
#[cfg(unix)]
#[test]
fn adopt_refuses_group_or_other_writable_authority_dir() {
use std::os::unix::fs::PermissionsExt;
let scratch = Scratch::new();
let kp = node_identity();
std::fs::set_permissions(scratch.dir(), std::fs::Permissions::from_mode(0o777))
.expect("chmod 0777");
let err = NodeAuthority::adopt(scratch.dir(), cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect_err("a group/other-writable authority dir must be refused");
assert!(
matches!(
&err,
OrgAuthorityError::InsecureAuthorityDir { reason, .. }
if reason.contains("group/other-writable")
),
"got: {err}",
);
let _ = std::fs::set_permissions(scratch.dir(), std::fs::Permissions::from_mode(0o700));
}
#[cfg(unix)]
#[test]
fn adopt_refuses_writable_nonsticky_ancestor() {
use std::os::unix::fs::PermissionsExt;
let scratch = Scratch::new();
let shared = scratch.dir().join("shared");
std::fs::create_dir_all(&shared).expect("mkdir shared");
std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o0777))
.expect("chmod 0777 non-sticky");
let kp = node_identity();
let err = NodeAuthority::adopt(
&shared.join("authority"),
cert_for(&kp, 1),
kp.entity_id(),
0,
None,
)
.expect_err("a writable-nonsticky ancestor must be refused");
assert!(
matches!(
&err,
OrgAuthorityError::InsecureAuthorityDir { reason, .. } if reason.contains("sticky")
),
"got: {err}",
);
let _ = std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o0755));
}
#[cfg(unix)]
#[test]
fn adopt_accepts_sticky_writable_ancestor() {
use std::os::unix::fs::PermissionsExt;
let scratch = Scratch::new();
let shared = scratch.dir().join("sticky-shared");
std::fs::create_dir_all(&shared).expect("mkdir");
std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o1777))
.expect("chmod 1777 sticky");
let kp = node_identity();
NodeAuthority::adopt(
&shared.join("authority"),
cert_for(&kp, 1),
kp.entity_id(),
0,
None,
)
.expect("a sticky writable ancestor with an owned child is accepted");
let _ = std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o0755));
}
#[cfg(unix)]
#[test]
fn adopt_refuses_symlinked_insecure_ancestor() {
use std::os::unix::fs::PermissionsExt;
let scratch = Scratch::new();
let insecure = scratch.dir().join("insecure");
std::fs::create_dir_all(&insecure).expect("mkdir insecure");
std::fs::set_permissions(&insecure, std::fs::Permissions::from_mode(0o0777))
.expect("chmod 0777");
let link = scratch.dir().join("link");
std::os::unix::fs::symlink(&insecure, &link).expect("symlink");
let kp = node_identity();
let err = NodeAuthority::adopt(
&link.join("authority"),
cert_for(&kp, 1),
kp.entity_id(),
0,
None,
)
.expect_err("a symlinked insecure ancestor must be refused");
assert!(
matches!(&err, OrgAuthorityError::InsecureAuthorityDir { .. }),
"got: {err}",
);
let _ = std::fs::set_permissions(&insecure, std::fs::Permissions::from_mode(0o0755));
}
#[test]
fn adopt_into_a_missing_subdir_creates_the_authority_dir() {
let scratch = Scratch::new();
let kp = node_identity();
let authority = scratch.dir().join("nested").join("authority");
NodeAuthority::adopt(&authority, cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect("adopt into a fresh subdir");
for name in NodeAuthority::file_names() {
assert!(authority.join(name).exists(), "{name} must be provisioned");
}
}
#[cfg(unix)]
#[test]
fn adopt_creates_intermediate_parents_owner_only_under_permissive_umask() {
run_in_isolated_child(
"adapter::net::behavior::org_authority::tests::\
adopt_creates_intermediate_parents_owner_only_under_permissive_umask",
|| {
use std::os::unix::fs::PermissionsExt;
let scratch = Scratch::new();
unsafe { libc::umask(0) };
let a = scratch.dir().join("a");
let b = a.join("b");
let authority = b.join("authority");
let kp = node_identity();
NodeAuthority::adopt(&authority, cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect("adopt creates a nested chain securely");
for comp in [&a, &b, &authority] {
let mode = std::fs::metadata(comp)
.expect("metadata")
.permissions()
.mode();
assert_eq!(
mode & 0o077,
0,
"{} must be owner-only (mode {mode:o})",
comp.display()
);
}
},
);
}
#[cfg(unix)]
#[test]
fn adopt_resolves_secure_relative_path_against_cwd() {
run_in_isolated_child(
"adapter::net::behavior::org_authority::tests::\
adopt_resolves_secure_relative_path_against_cwd",
|| {
use std::os::unix::fs::PermissionsExt;
let scratch = Scratch::new();
std::fs::set_permissions(scratch.dir(), std::fs::Permissions::from_mode(0o700))
.expect("chmod 0700");
std::env::set_current_dir(scratch.dir()).expect("set cwd");
let kp = node_identity();
NodeAuthority::adopt(
Path::new("authority"),
cert_for(&kp, 1),
kp.entity_id(),
0,
None,
)
.expect("a relative authority path under a secure cwd must adopt");
assert!(
scratch
.dir()
.join("authority")
.join(OWNER_MEMBERSHIP_FILE)
.exists(),
"the authority dir must be created under the resolved cwd",
);
},
);
}
#[cfg(unix)]
#[test]
fn adopt_refuses_relative_path_under_writable_nonsticky_cwd() {
run_in_isolated_child(
"adapter::net::behavior::org_authority::tests::\
adopt_refuses_relative_path_under_writable_nonsticky_cwd",
|| {
use std::os::unix::fs::PermissionsExt;
let scratch = Scratch::new();
std::fs::set_permissions(scratch.dir(), std::fs::Permissions::from_mode(0o777))
.expect("chmod 0777");
std::env::set_current_dir(scratch.dir()).expect("set cwd");
let kp = node_identity();
let err = NodeAuthority::adopt(
Path::new("authority"),
cert_for(&kp, 1),
kp.entity_id(),
0,
None,
)
.expect_err("a relative path under a writable-nonsticky cwd must be refused");
assert!(
matches!(&err, OrgAuthorityError::InsecureAuthorityDir { .. }),
"got: {err}",
);
assert!(
!scratch.dir().join("authority").exists(),
"no authority dir may be created when the cwd chain is refused",
);
},
);
}
#[cfg(unix)]
#[test]
fn adopt_refuses_relative_path_beneath_foreign_owned_ancestor() {
run_in_isolated_child(
"adapter::net::behavior::org_authority::tests::\
adopt_refuses_relative_path_beneath_foreign_owned_ancestor",
|| {
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::DirBuilderExt;
if unsafe { libc::geteuid() } != 0 {
eprintln!("skipped: requires root to create a foreign-owned ancestor");
return;
}
let scratch = Scratch::new(); let foreign = scratch.dir().join("foreign");
std::fs::DirBuilder::new()
.mode(0o755)
.create(&foreign)
.expect("mkdir foreign");
let cpath =
std::ffi::CString::new(foreign.as_os_str().as_bytes()).expect("cstring");
let rc = unsafe { libc::chown(cpath.as_ptr(), 12345, 12345) };
assert_eq!(rc, 0, "chown to a foreign uid must succeed as root");
let work = foreign.join("work");
std::fs::DirBuilder::new()
.mode(0o700)
.create(&work)
.expect("mkdir work");
std::env::set_current_dir(&work).expect("set cwd");
let kp = node_identity();
let err = NodeAuthority::adopt(
Path::new("authority"),
cert_for(&kp, 1),
kp.entity_id(),
0,
None,
)
.expect_err("a relative path beneath a foreign-owned ancestor must be refused");
assert!(
matches!(&err, OrgAuthorityError::InsecureAuthorityDir { .. }),
"got: {err}",
);
},
);
}
#[cfg(unix)]
#[test]
fn adopt_creates_relative_nested_missing_chain_owner_only() {
run_in_isolated_child(
"adapter::net::behavior::org_authority::tests::\
adopt_creates_relative_nested_missing_chain_owner_only",
|| {
use std::os::unix::fs::PermissionsExt;
let scratch = Scratch::new();
std::fs::set_permissions(scratch.dir(), std::fs::Permissions::from_mode(0o700))
.expect("chmod 0700");
std::env::set_current_dir(scratch.dir()).expect("set cwd");
unsafe { libc::umask(0) };
let kp = node_identity();
NodeAuthority::adopt(
Path::new("nested/authority"),
cert_for(&kp, 1),
kp.entity_id(),
0,
None,
)
.expect("a relative nested chain under a secure cwd must adopt");
for rel in ["nested", "nested/authority"] {
let mode = std::fs::metadata(scratch.dir().join(rel))
.expect("metadata")
.permissions()
.mode();
assert_eq!(mode & 0o077, 0, "{rel} must be owner-only (mode {mode:o})");
}
assert!(scratch
.dir()
.join("nested/authority")
.join(OWNER_MEMBERSHIP_FILE)
.exists());
},
);
}
#[cfg(unix)]
#[test]
fn adopt_refuses_final_symlink_even_with_trailing_separator() {
use std::os::unix::fs::PermissionsExt;
let scratch = Scratch::new();
std::fs::set_permissions(scratch.dir(), std::fs::Permissions::from_mode(0o700))
.expect("chmod 0700");
let target = scratch.dir().join("target");
std::fs::create_dir(&target).expect("mkdir target");
let link = scratch.dir().join("link");
std::os::unix::fs::symlink(&target, &link).expect("symlink");
let kp = node_identity();
let bare = NodeAuthority::adopt(&link, cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect_err("a final-symlink authority dir must be refused");
assert!(
matches!(&bare, OrgAuthorityError::InsecureAuthorityDir { .. }),
"got: {bare}",
);
let mut trailing = link.clone().into_os_string();
trailing.push("/");
let slashed = NodeAuthority::adopt(
Path::new(&trailing),
cert_for(&kp, 1),
kp.entity_id(),
0,
None,
)
.expect_err("a final-symlink authority dir with a trailing '/' must be refused");
assert!(
matches!(&slashed, OrgAuthorityError::InsecureAuthorityDir { .. }),
"got: {slashed}",
);
assert!(
!target.join(OWNER_MEMBERSHIP_FILE).exists(),
"refusing the symlink must not provision into its target",
);
}
#[cfg(windows)]
#[test]
fn adopt_windows_authority_dir_and_files_are_owner_only() {
const OI: u8 = 0x01;
const CI: u8 = 0x02;
const FILE_ALL_ACCESS: u32 = 0x001F_01FF;
const LOCAL_SYSTEM: &str = "S-1-5-18";
const ADMINISTRATORS: &str = "S-1-5-32-544";
let scratch = Scratch::new();
let kp = node_identity();
let authority = scratch.dir().join("nested").join("authority");
NodeAuthority::adopt(&authority, cert_for(&kp, 1), kp.entity_id(), 0, None).expect("adopt");
let user = current_process_sid_string().expect("user sid");
let trusted = |sid: &str| sid == user || sid == LOCAL_SYSTEM || sid == ADMINISTRATORS;
let dir_view = read_object_security(&authority).expect("read dir sd");
assert!(!dir_view.null_dacl, "dir DACL must not be NULL");
assert!(
dir_view.protected,
"dir DACL must be protected (parent inheritance stripped)"
);
assert!(
trusted(&dir_view.owner_sid),
"dir owner must be a trusted principal, got {}",
dir_view.owner_sid
);
let owner_ace = dir_view
.aces
.iter()
.find(|a| a.ace_type == 0 && a.sid == user)
.expect("dir must carry an allowed ACE for the owner");
assert_eq!(
owner_ace.mask & FILE_ALL_ACCESS,
FILE_ALL_ACCESS,
"owner ACE must grant full control"
);
assert_eq!(
owner_ace.flags & (OI | CI),
OI | CI,
"owner ACE must be object+container inheritable"
);
for ace in &dir_view.aces {
if ace.ace_type == 0 && ace.mask & WRITE_MASK != 0 {
assert!(
trusted(&ace.sid),
"dir grants write to non-trusted {}",
ace.sid
);
}
}
let owner_only = |view: &DaclView| -> bool {
let owner_full = view.aces.iter().any(|a| {
a.ace_type == 0 && a.sid == user && a.mask & FILE_ALL_ACCESS == FILE_ALL_ACCESS
});
let no_foreign_write = view
.aces
.iter()
.all(|a| a.ace_type != 0 || a.mask & WRITE_MASK == 0 || trusted(&a.sid));
!view.null_dacl && owner_full && no_foreign_write
};
let probe = authority.join("probe");
std::fs::write(&probe, b"x").expect("write probe file");
let probe_view = read_object_security(&probe).expect("read probe sd");
assert!(
owner_only(&probe_view),
"a file created in the protected dir must be owner-only; got {:?}",
probe_view.aces,
);
let file_view =
read_object_security(&authority.join(OWNER_MEMBERSHIP_FILE)).expect("read file sd");
assert!(
owner_only(&file_view),
"a provisioned authority file must be owner-only; got {:?}",
file_view.aces,
);
}
#[cfg(windows)]
#[test]
fn existing_windows_dir_dacl_is_revalidated_binary() {
let scratch = Scratch::new();
let kp = node_identity();
let ok_dir = scratch.dir().join("secure");
NodeAuthority::adopt(&ok_dir, cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect("adopt secure");
validate_existing_dir_dacl(&ok_dir).expect("an owner-only dir must validate");
let bad_dir = scratch.dir().join("permissive");
std::fs::create_dir(&bad_dir).expect("mkdir permissive");
let status = std::process::Command::new("icacls")
.arg(&bad_dir)
.arg("/grant")
.arg("*S-1-1-0:(OI)(CI)F") .status()
.expect("run icacls");
assert!(status.success(), "icacls grant Everyone must succeed");
let err = validate_existing_dir_dacl(&bad_dir)
.expect_err("a dir granting Everyone write must be refused");
assert!(
matches!(&err, OrgAuthorityError::InsecureAuthorityDir { .. }),
"got: {err}",
);
let adopt_err = NodeAuthority::adopt(&bad_dir, cert_for(&kp, 2), kp.entity_id(), 0, None)
.expect_err("adopt into an Everyone-writable dir must be refused");
assert!(
matches!(&adopt_err, OrgAuthorityError::InsecureAuthorityDir { .. }),
"got: {adopt_err}",
);
}
#[cfg(windows)]
#[allow(clippy::multiple_unsafe_ops_per_block)]
fn apply_sddl(path: &Path, sddl: &str) -> std::io::Result<()> {
use std::os::windows::ffi::OsStrExt;
type Pv = *mut std::ffi::c_void;
extern "system" {
fn ConvertStringSecurityDescriptorToSecurityDescriptorW(
sddl: *const u16,
revision: u32,
sd: *mut Pv,
size: *mut u32,
) -> i32;
fn SetFileSecurityW(name: *const u16, info: u32, sd: Pv) -> i32;
fn LocalFree(mem: Pv) -> Pv;
}
const SDDL_REVISION_1: u32 = 1;
const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004;
const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x8000_0000;
let mut wide_path: Vec<u16> = path.as_os_str().encode_wide().collect();
wide_path.push(0);
let mut wide_sddl: Vec<u16> = std::ffi::OsStr::new(sddl).encode_wide().collect();
wide_sddl.push(0);
unsafe {
let mut sd: Pv = std::ptr::null_mut();
if ConvertStringSecurityDescriptorToSecurityDescriptorW(
wide_sddl.as_ptr(),
SDDL_REVISION_1,
&mut sd,
std::ptr::null_mut(),
) == 0
{
return Err(std::io::Error::last_os_error());
}
let ok = SetFileSecurityW(
wide_path.as_ptr(),
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
sd,
);
LocalFree(sd);
if ok == 0 {
return Err(std::io::Error::last_os_error());
}
}
Ok(())
}
#[cfg(windows)]
#[test]
fn a_foreign_owned_dir_fails_closed_despite_a_clean_dacl() {
const FILE_ALL_ACCESS: u32 = 0x001F_01FF;
let user = current_process_sid_string().expect("user sid");
let foreign = "S-1-5-32-546";
assert_ne!(user, foreign, "fixture SID must not be the test principal");
let clean_ace = AceInfo {
sid: user.clone(),
mask: FILE_ALL_ACCESS,
ace_type: 0,
flags: 0x03, };
let dir = Path::new("C:\\ProgramData\\net-authority");
let owned_by_us = DaclView {
owner_sid: user.clone(),
protected: true,
null_dacl: false,
aces: vec![clean_ace.clone()],
};
validate_dacl_view(&owned_by_us, &user, dir)
.expect("an owner-only dir owned by the current user validates");
let owned_by_foreign = DaclView {
owner_sid: foreign.to_string(),
protected: true,
null_dacl: false,
aces: vec![clean_ace],
};
let err = validate_dacl_view(&owned_by_foreign, &user, dir)
.expect_err("a foreign-owned authority directory must be refused");
match &err {
OrgAuthorityError::InsecureAuthorityDir { reason, .. } => assert!(
reason.contains("owned by untrusted principal") && reason.contains(foreign),
"the refusal must name ownership as the cause and identify the owner; got: {reason}",
),
other => panic!("wrong error variant: {other}"),
}
}
#[cfg(windows)]
#[test]
fn windows_ancestor_chain_accepts_a_normal_path() {
let scratch = Scratch::new();
let nested = scratch.dir().join("a").join("b");
validate_windows_ancestor_chain(&nested)
.expect("a user-owned ancestor chain must validate");
std::fs::create_dir_all(&nested).expect("create nested");
validate_windows_ancestor_chain(&nested).expect("the completed chain must validate too");
}
#[cfg(windows)]
#[test]
fn a_foreign_owned_ancestor_is_refused() {
let user = current_process_sid_string().expect("user sid");
let foreign = "S-1-5-21-1111111111-2222222222-3333333333-1001";
let dir = Path::new("C:\\ProgramData\\net-authority");
let ace = AceInfo {
sid: user.clone(),
mask: 0x001F_01FF,
ace_type: 0,
flags: 0x03,
};
let ours = DaclView {
owner_sid: user.clone(),
protected: true,
null_dacl: false,
aces: vec![ace.clone()],
};
validate_dacl_view(&ours, &user, dir).expect("a user-owned component validates");
let theirs = DaclView {
owner_sid: foreign.to_string(),
protected: true,
null_dacl: false,
aces: vec![ace],
};
let err = validate_dacl_view(&theirs, &user, dir)
.expect_err("a foreign-owned component must be refused");
match &err {
OrgAuthorityError::InsecureAuthorityDir { reason, .. } => assert!(
reason.contains("WRITE_DAC") && reason.contains(foreign),
"the refusal must explain WHY ownership is the criterion; got: {reason}",
),
other => panic!("wrong error variant: {other}"),
}
}
#[cfg(windows)]
#[test]
fn adopting_into_an_inheriting_directory_severs_inheritance() {
let scratch = Scratch::new();
let dir = scratch.dir().join("pre-existing");
std::fs::create_dir_all(&dir).expect("plain mkdir");
let before = read_object_security(&dir).expect("read before");
assert!(
!before.protected,
"precondition: a plainly-created directory must be unprotected, \
else the repair under test is never exercised",
);
let kp = node_identity();
let authority = NodeAuthority::adopt(&dir, cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect("adopt into a pre-existing inheriting directory must succeed");
assert_eq!(authority.owner_org(), org().org_id());
let after = read_object_security(&dir).expect("read after");
assert!(
after.protected,
"adopt must sever inheritance on the directory it provisions secrets into",
);
}
#[cfg(windows)]
#[test]
fn an_unprotected_dacl_is_refused_even_when_its_aces_are_clean() {
let user = current_process_sid_string().expect("user sid");
let dir = Path::new("C:\\ProgramData\\net-authority");
let clean_ace = AceInfo {
sid: user.clone(),
mask: 0x001F_01FF, ace_type: 0,
flags: 0x03, };
let protected = DaclView {
owner_sid: user.clone(),
protected: true,
null_dacl: false,
aces: vec![clean_ace.clone()],
};
validate_dacl_view(&protected, &user, dir)
.expect("a protected owner-only dir must validate");
let unprotected = DaclView {
owner_sid: user.clone(),
protected: false,
null_dacl: false,
aces: vec![clean_ace],
};
let err = validate_dacl_view(&unprotected, &user, dir)
.expect_err("an unprotected authority directory must be refused");
match &err {
OrgAuthorityError::InsecureAuthorityDir { reason, .. } => assert!(
reason.contains("PROTECTED") && reason.contains("inherit"),
"the refusal must name inheritance as the cause, so an operator \
knows to sever it rather than hunting for a bad ace; got: {reason}",
),
other => panic!("wrong error variant: {other}"),
}
}
#[cfg(windows)]
#[test]
fn an_untrusted_read_ace_on_the_audience_key_is_refused() {
let user = current_process_sid_string().expect("user sid");
let everyone = "S-1-1-0";
let key = Path::new("C:\\ProgramData\\net-authority\\owner-audience.key");
let owner_ace = AceInfo {
sid: user.clone(),
mask: 0x001F_01FF,
ace_type: 0,
flags: 0x10, };
let clean = DaclView {
owner_sid: user.clone(),
protected: false,
null_dacl: false,
aces: vec![owner_ace.clone()],
};
validate_audience_acl_view(&clean, &user, key)
.expect("an owner-only inherited ace must validate");
let leaked = DaclView {
owner_sid: user.clone(),
protected: false,
null_dacl: false,
aces: vec![
owner_ace,
AceInfo {
sid: everyone.to_string(),
mask: 0x0000_0001, ace_type: 0,
flags: 0x00, },
],
};
let err = validate_audience_acl_view(&leaked, &user, key)
.expect_err("a read ace for Everyone on the audience key must refuse");
match &err {
OrgAuthorityError::PermissiveAudienceAcl { reason, .. } => assert!(
reason.contains(everyone),
"the refusal must name the principal; got: {reason}",
),
other => panic!("wrong error variant: {other}"),
}
}
#[cfg(windows)]
#[test]
fn a_foreign_owned_audience_key_is_refused() {
let user = current_process_sid_string().expect("user sid");
let foreign = "S-1-5-21-1111111111-2222222222-3333333333-1001";
let key = Path::new("C:\\ProgramData\\net-authority\\owner-audience.key");
let only_us = AceInfo {
sid: user.clone(),
mask: 0x001F_01FF,
ace_type: 0,
flags: 0x10,
};
let view = DaclView {
owner_sid: foreign.to_string(),
protected: false,
null_dacl: false,
aces: vec![only_us],
};
let err = validate_audience_acl_view(&view, &user, key)
.expect_err("a foreign-owned audience key must refuse");
match &err {
OrgAuthorityError::PermissiveAudienceAcl { reason, .. } => assert!(
reason.contains("WRITE_DAC") && reason.contains(foreign),
"the refusal must explain WHY ownership matters; got: {reason}",
),
other => panic!("wrong error variant: {other}"),
}
}
#[cfg(windows)]
#[test]
fn every_non_simple_grant_type_fails_closed_when_write_capable() {
let user = current_process_sid_string().expect("user sid");
let dir = Path::new("C:\\ProgramData\\net-authority");
let view_with = |ace_type: u8, mask: u32, flags: u8| DaclView {
owner_sid: user.clone(),
protected: true,
null_dacl: false,
aces: vec![
AceInfo {
sid: user.clone(),
mask: 0x001F_01FF,
ace_type: 0,
flags: 0x03,
},
AceInfo {
sid: NON_SIMPLE_ACE_SID.to_string(),
mask,
ace_type,
flags,
},
],
};
for ace_type in [5u8, 9, 11] {
let err = validate_dacl_view(&view_with(ace_type, WRITE_MASK, 0x00), &user, dir)
.expect_err("a write-capable unparsed ACE must be refused");
assert!(
matches!(&err, OrgAuthorityError::InsecureAuthorityDir { .. }),
"ace_type {ace_type}: got {err}",
);
}
validate_dacl_view(&view_with(9, 0x0000_0001, 0x00), &user, dir)
.expect("a read-only NON-inheriting non-simple ACE is tolerated");
for flags in [0x01u8, 0x02, 0x03] {
let err = validate_dacl_view(&view_with(9, 0x0000_0001, flags), &user, dir)
.expect_err("an inheritable untrusted ace must be refused even read-only");
match &err {
OrgAuthorityError::InsecureAuthorityDir { reason, .. } => assert!(
reason.contains("INHERITABLE"),
"flags {flags:#04x}: the refusal must cite inheritance; got {reason}",
),
other => panic!("flags {flags:#04x}: wrong variant: {other}"),
}
}
for ace_type in [1u8, 6, 10, 12] {
validate_dacl_view(&view_with(ace_type, WRITE_MASK, 0x03), &user, dir)
.unwrap_or_else(|e| panic!("deny ace_type {ace_type} must not refuse: {e}"));
}
}
#[cfg(windows)]
#[test]
fn a_non_simple_write_capable_ace_fails_closed() {
let scratch = Scratch::new();
let dir = scratch.dir().join("conditional-ace");
std::fs::create_dir(&dir).expect("mkdir");
apply_sddl(&dir, "D:P(XA;OICI;FA;;;WD;(Member_of{SID(WD)}))")
.expect("applying the conditional ACE must succeed");
let view = read_object_security(&dir).expect("read sd");
let sentinel_write = view
.aces
.iter()
.find(|a| a.sid == NON_SIMPLE_ACE_SID && a.mask & WRITE_MASK != 0)
.unwrap_or_else(|| {
panic!(
"expected a write-capable non-simple ACE; got {:?}",
view.aces
)
});
assert_ne!(
sentinel_write.ace_type, 0,
"the ACE under test must not be a simple ALLOWED ace",
);
let err = validate_existing_dir_dacl(&dir)
.expect_err("a write-capable non-simple ACE must be refused");
assert!(
matches!(&err, OrgAuthorityError::InsecureAuthorityDir { .. }),
"got: {err}",
);
let kp = node_identity();
let adopt_err = NodeAuthority::adopt(&dir, cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect_err("adopt into a conditionally-world-writable dir must be refused");
assert!(
matches!(&adopt_err, OrgAuthorityError::InsecureAuthorityDir { .. }),
"got: {adopt_err}",
);
}
#[cfg(windows)]
#[test]
fn an_untrusted_inheritable_read_ace_is_refused_and_never_reaches_the_key() {
const FILE_READ_DATA: u32 = 0x0000_0001;
const EVERYONE: &str = "S-1-1-0";
let scratch = Scratch::new();
let kp = node_identity();
let user = current_process_sid_string().expect("user sid");
let dir = scratch.dir().join("inheritable-read");
std::fs::create_dir(&dir).expect("mkdir");
apply_sddl(&dir, &format!("D:P(A;OICI;FA;;;{user})(A;OICI;FR;;;WD)"))
.expect("apply inheritable read ace");
let view = read_object_security(&dir).expect("read dir sd");
let probe = view
.aces
.iter()
.find(|a| a.sid == EVERYONE)
.expect("Everyone ace present");
assert_eq!(
probe.mask & WRITE_MASK,
0,
"the ace under test is read-only"
);
assert_ne!(
probe.flags & 0x01,
0,
"the ace under test is OBJECT_INHERIT"
);
let err = validate_existing_dir_dacl(&dir)
.expect_err("an untrusted inheritable ace must be refused");
match &err {
OrgAuthorityError::InsecureAuthorityDir { reason, .. } => assert!(
reason.contains("INHERITABLE"),
"the refusal must name inheritance as the cause; got: {reason}",
),
other => panic!("wrong error variant: {other}"),
}
let adopt_err = NodeAuthority::adopt(&dir, cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect_err("adopt into an inheritable-read dir must be refused");
assert!(
matches!(&adopt_err, OrgAuthorityError::InsecureAuthorityDir { .. }),
"got: {adopt_err}",
);
assert!(
!dir.join(OWNER_AUDIENCE_FILE).exists(),
"a refused adoption must provision no key material",
);
let ok_dir = scratch.dir().join("owner-only");
std::fs::create_dir(&ok_dir).expect("mkdir");
apply_sddl(&ok_dir, &format!("D:P(A;OICI;FA;;;{user})")).expect("apply owner-only");
validate_existing_dir_dacl(&ok_dir).expect("an owner-only dir validates");
NodeAuthority::adopt(&ok_dir, cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect("adopt into an owner-only dir");
let key_view =
read_object_security(&ok_dir.join(OWNER_AUDIENCE_FILE)).expect("read key sd");
assert!(
!key_view
.aces
.iter()
.any(|a| a.ace_type == 0 && a.sid == EVERYONE && a.mask & FILE_READ_DATA != 0),
"Everyone must not be able to read the audience key; got {:?}",
key_view.aces,
);
}
#[cfg(windows)]
#[test]
fn a_deny_ace_does_not_make_an_owner_only_dir_invalid() {
let scratch = Scratch::new();
let kp = node_identity();
let dir = scratch.dir().join("with-deny");
NodeAuthority::adopt(&dir, cert_for(&kp, 1), kp.entity_id(), 0, None).expect("adopt");
validate_existing_dir_dacl(&dir).expect("baseline owner-only dir validates");
let status = std::process::Command::new("icacls")
.arg(&dir)
.arg("/deny")
.arg("*S-1-1-0:(OI)(CI)W")
.status()
.expect("run icacls /deny");
assert!(status.success(), "icacls deny must succeed");
let view = read_object_security(&dir).expect("read sd");
assert!(
view.aces.iter().any(|a| a.ace_type == 1),
"precondition: a simple DENY ace must be present; got {:?}",
view.aces,
);
validate_existing_dir_dacl(&dir)
.expect("a DENY ace must not invalidate an owner-only directory");
}
#[cfg(windows)]
#[test]
fn adopt_fails_closed_on_uncreatable_windows_path_without_residue() {
let scratch = Scratch::new();
let kp = node_identity();
let bad_parent = scratch.dir().join("inva|lid");
let authority = bad_parent.join("authority");
let e1 = NodeAuthority::adopt(&authority, cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect_err("adopt onto an uncreatable path must fail");
assert!(matches!(&e1, OrgAuthorityError::Io { .. }), "got: {e1}");
assert!(
!bad_parent.exists(),
"no residual directory may be left behind",
);
for name in NodeAuthority::file_names() {
assert!(
!authority.join(name).exists(),
"no authority file may be provisioned on failure",
);
}
let e2 = NodeAuthority::adopt(&authority, cert_for(&kp, 2), kp.entity_id(), 0, None)
.expect_err("retry must remain fail-closed");
assert!(matches!(&e2, OrgAuthorityError::Io { .. }), "got: {e2}");
}
#[cfg(windows)]
#[test]
fn audience_acl_read_via_open_handle_matches_the_path_read() {
use std::os::windows::io::AsRawHandle;
let scratch = Scratch::new();
let path = scratch.dir().join("grant.audience");
std::fs::write(&path, b"raw-owner-discovery-key").expect("write secret");
let by_path = read_object_security(&path).expect("path read");
let file = std::fs::File::open(&path).expect("open");
let by_handle = read_object_security_handle(file.as_raw_handle()).expect("handle read");
assert_eq!(by_handle.owner_sid, by_path.owner_sid, "owner");
assert_eq!(by_handle.null_dacl, by_path.null_dacl, "null_dacl");
assert_eq!(by_handle.protected, by_path.protected, "protected");
assert_eq!(by_handle.aces.len(), by_path.aces.len(), "ace count");
for (h, p) in by_handle.aces.iter().zip(by_path.aces.iter()) {
assert_eq!((h.ace_type, h.mask, &h.sid), (p.ace_type, p.mask, &p.sid));
}
assert_eq!(
validate_audience_file_acl_handle(&file, &path).is_ok(),
validate_audience_file_acl(&path).is_ok(),
"handle and path validators must agree",
);
}
#[test]
fn readopt_same_org_preserves_audience_and_floors() {
let scratch = Scratch::new();
let kp = node_identity();
let first = NodeAuthority::adopt(scratch.dir(), cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect("adopt");
let mut floors = BTreeMap::new();
floors.insert(EntityId::from_bytes([9u8; 32]), 7u32);
let bundle = OrgRevocationBundle::try_issue(&org(), &floors).expect("issue");
first.revocation.apply_bundle(&bundle).expect("apply");
let handle_before = first.audience.audience_handle;
drop(first);
let second = NodeAuthority::adopt(scratch.dir(), cert_for(&kp, 2), kp.entity_id(), 0, None)
.expect("re-adopt");
assert_eq!(
second.audience.audience_handle, handle_before,
"re-adopt must preserve the audience credential"
);
assert_eq!(
second
.revocation
.floor_for(&org().org_id(), &EntityId::from_bytes([9u8; 32])),
7,
"re-adopt must preserve persisted floors"
);
assert_eq!(second.config.owner_cert.generation, 2);
}
#[test]
fn adopt_refuses_second_owner() {
let scratch = Scratch::new();
let kp = node_identity();
NodeAuthority::adopt(scratch.dir(), cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect("adopt");
let other_org = OrgKeypair::from_bytes([0x99u8; 32]);
let foreign_cert =
OrgMembershipCert::try_issue(&other_org, kp.entity_id().clone(), 1, 3600)
.expect("issue");
let err = NodeAuthority::adopt(scratch.dir(), foreign_cert, kp.entity_id(), 0, None)
.expect_err("one node one owner");
assert!(matches!(err, OrgAuthorityError::AlreadyOwned { .. }));
}
#[test]
fn adopt_refuses_cert_for_another_entity_and_expired_cert() {
let scratch = Scratch::new();
let kp = node_identity();
let stranger = EntityKeypair::from_bytes([0x55u8; 32]);
let err = NodeAuthority::adopt(
scratch.dir(),
cert_for(&stranger, 1),
kp.entity_id(),
0,
None,
)
.expect_err("wrong member");
assert!(matches!(err, OrgAuthorityError::CertNotForThisNode { .. }));
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_secs();
let expired = OrgMembershipCert::issue_at(
&org(),
kp.entity_id().clone(),
1,
now - 2000,
now - 1000,
7,
);
let err = NodeAuthority::adopt(scratch.dir(), expired, kp.entity_id(), 0, None)
.expect_err("expired");
assert!(matches!(err, OrgAuthorityError::CertInvalid(_)));
assert!(!scratch.dir().join(OWNER_MEMBERSHIP_FILE).exists());
}
#[test]
fn open_is_loud_on_missing_or_corrupt_files() {
let scratch = Scratch::new();
let kp = node_identity();
let err = NodeAuthority::open(scratch.dir(), kp.entity_id()).expect_err("missing");
assert!(matches!(err, OrgAuthorityError::MissingFile { .. }));
NodeAuthority::adopt(scratch.dir(), cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect("adopt");
std::fs::write(scratch.dir().join(OWNER_MEMBERSHIP_FILE), b"{ nope").expect("write");
let err = NodeAuthority::open(scratch.dir(), kp.entity_id()).expect_err("corrupt");
assert!(matches!(err, OrgAuthorityError::CorruptFile { .. }));
let err = NodeAuthority::adopt(scratch.dir(), cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect_err("adopt over corrupt membership is loud");
assert!(matches!(err, OrgAuthorityError::CorruptFile { .. }));
std::fs::remove_file(scratch.dir().join(OWNER_MEMBERSHIP_FILE)).expect("remove");
NodeAuthority::adopt(scratch.dir(), cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect("adopt");
std::fs::write(scratch.dir().join(OWNER_AUDIENCE_FILE), [1u8; 10]).expect("write");
let err = NodeAuthority::open(scratch.dir(), kp.entity_id()).expect_err("corrupt key");
assert!(matches!(err, OrgAuthorityError::CorruptFile { .. }));
let mut bad = [0u8; OwnerAudienceCredential::ENCODED_SIZE];
bad[0] = 9;
std::fs::write(scratch.dir().join(OWNER_AUDIENCE_FILE), bad).expect("write");
let err = NodeAuthority::open(scratch.dir(), kp.entity_id()).expect_err("bad version");
assert!(matches!(err, OrgAuthorityError::UnsupportedVersion { .. }));
std::fs::remove_file(scratch.dir().join(OWNER_AUDIENCE_FILE)).expect("remove");
NodeAuthority::adopt(scratch.dir(), cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect("adopt");
std::fs::remove_file(scratch.dir().join(REVOCATION_STATE_FILE)).expect("remove");
let err = NodeAuthority::open(scratch.dir(), kp.entity_id()).expect_err("no floors");
assert!(matches!(err, OrgAuthorityError::Revocation(_)));
}
#[test]
fn open_refuses_floored_cert() {
let scratch = Scratch::new();
let kp = node_identity();
let authority =
NodeAuthority::adopt(scratch.dir(), cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect("adopt");
let mut floors = BTreeMap::new();
floors.insert(kp.entity_id().clone(), 5u32);
let bundle = OrgRevocationBundle::try_issue(&org(), &floors).expect("issue");
authority.revocation.apply_bundle(&bundle).expect("apply");
drop(authority);
let err = NodeAuthority::open(scratch.dir(), kp.entity_id()).expect_err("floored");
assert!(matches!(
err,
OrgAuthorityError::CertBelowFloor {
generation: 1,
floor: 5
}
));
NodeAuthority::adopt(scratch.dir(), cert_for(&kp, 5), kp.entity_id(), 0, None)
.expect("renew at floor");
NodeAuthority::open(scratch.dir(), kp.entity_id()).expect("open after renewal");
}
#[test]
fn membership_file_rejects_org_cert_mismatch() {
let scratch = Scratch::new();
let kp = node_identity();
NodeAuthority::adopt(scratch.dir(), cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect("adopt");
let org_b = OrgKeypair::from_bytes([0x99u8; 32]);
let path = scratch.dir().join(OWNER_MEMBERSHIP_FILE);
let mut config: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).expect("read")).expect("parse");
config["owner_org"] = serde_json::Value::String(hex::encode(org_b.org_id().as_bytes()));
std::fs::write(&path, serde_json::to_vec(&config).expect("ser")).expect("write");
let tampered = std::fs::read(&path).expect("read tampered");
let err = NodeAuthority::open(scratch.dir(), kp.entity_id()).expect_err("mismatch");
assert!(matches!(err, OrgAuthorityError::OwnerOrgMismatch { .. }));
let cert_b =
OrgMembershipCert::try_issue(&org_b, kp.entity_id().clone(), 1, 3600).expect("issue B");
let err = NodeAuthority::adopt(scratch.dir(), cert_b, kp.entity_id(), 0, None)
.expect_err("inconsistent existing membership must refuse re-adoption");
assert!(matches!(err, OrgAuthorityError::OwnerOrgMismatch { .. }));
assert_eq!(std::fs::read(&path).expect("read"), tampered);
}
#[test]
fn concurrent_first_adoptions_admit_exactly_one_owner() {
for attempt in 0..8 {
let scratch = Scratch::new();
let kp = node_identity();
let org_b = OrgKeypair::from_bytes([0x99u8; 32]);
let cert_a = cert_for(&kp, 1);
let cert_b = OrgMembershipCert::try_issue(&org_b, kp.entity_id().clone(), 1, 3600)
.expect("issue B");
let dir_a = scratch.dir().to_path_buf();
let dir_b = scratch.dir().to_path_buf();
let entity_a = kp.entity_id().clone();
let entity_b = kp.entity_id().clone();
let t_a = std::thread::spawn(move || {
NodeAuthority::adopt(&dir_a, cert_a, &entity_a, 0, None).map(|a| a.owner_org())
});
let t_b = std::thread::spawn(move || {
NodeAuthority::adopt(&dir_b, cert_b, &entity_b, 0, None).map(|a| a.owner_org())
});
let result_a = t_a.join().expect("A thread");
let result_b = t_b.join().expect("B thread");
let winners = [result_a.is_ok(), result_b.is_ok()]
.iter()
.filter(|ok| **ok)
.count();
assert_eq!(
winners, 1,
"attempt {attempt}: exactly one adoption may win"
);
let (winner_org, loser) = match (result_a, result_b) {
(Ok(org), loser) => (org, loser),
(loser, Ok(org)) => (org, loser),
(Err(a), Err(b)) => panic!("attempt {attempt}: no winner ({a}; {b})"),
};
let loser_err = loser.expect_err("loser refuses");
assert!(
matches!(loser_err, OrgAuthorityError::AlreadyOwned { .. }),
"attempt {attempt}: loser must see AlreadyOwned, got {loser_err}"
);
let opened = NodeAuthority::open(scratch.dir(), kp.entity_id()).expect("open");
assert_eq!(opened.owner_org(), winner_org);
}
}
#[test]
fn adopt_racing_floor_raise_never_installs_revoked_cert() {
let scratch = Scratch::new();
let kp = node_identity();
let revocation_path = scratch.dir().join(REVOCATION_STATE_FILE);
let raise_path = revocation_path.clone();
let member = kp.entity_id().clone();
let (started_tx, started_rx) = std::sync::mpsc::channel::<()>();
let raiser = std::thread::spawn(move || {
std::fs::create_dir_all(raise_path.parent().expect("parent")).expect("mkdir");
let store = OrgRevocationStore::init(&raise_path, ProvisioningExpectation::MayBeFresh)
.expect("init");
started_tx.send(()).expect("signal");
let mut floors = BTreeMap::new();
floors.insert(member, 5u32);
let bundle = OrgRevocationBundle::try_issue(&org(), &floors).expect("issue");
store.apply_bundle(&bundle).expect("raise to 5");
});
started_rx.recv().expect("raiser started");
let adoption =
NodeAuthority::adopt(scratch.dir(), cert_for(&kp, 3), kp.entity_id(), 0, None);
raiser.join().expect("raiser join");
match &adoption {
Err(e) => assert!(
matches!(e, OrgAuthorityError::CertBelowFloor { .. }),
"refusal must be the floor, got {e}"
),
Ok(authority) => assert_eq!(authority.config.owner_cert.generation, 3),
}
let err = NodeAuthority::open(scratch.dir(), kp.entity_id())
.expect_err("startup must never accept the floored cert");
let published = scratch.dir().join(OWNER_MEMBERSHIP_FILE).exists();
match (&err, published) {
(OrgAuthorityError::CertBelowFloor { .. }, true) => {}
(OrgAuthorityError::MissingFile { .. }, false) => {}
_ => panic!(
"startup refusal must match what the ceremony published \
(published={published}), got {err}"
),
}
if adoption.is_err() && published {
let bytes = std::fs::read(scratch.dir().join(OWNER_MEMBERSHIP_FILE)).expect("read");
let config: NodeAuthorityConfig =
serde_json::from_slice(&bytes).expect("published membership parses");
assert_eq!(
config.owner_cert.generation, 3,
"the published membership must be this ceremony's candidate"
);
}
}
#[test]
fn persisted_skew_carries_from_ceremony_to_startup() {
let scratch = Scratch::new();
let kp = node_identity();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_secs();
let expired =
OrgMembershipCert::issue_at(&org(), kp.entity_id().clone(), 1, now - 3600, now - 30, 7);
let authority = NodeAuthority::adopt(scratch.dir(), expired, kp.entity_id(), 120, None)
.expect("skew-120 ceremony accepts");
assert_eq!(authority.config.verification_skew_secs, 120);
NodeAuthority::open(scratch.dir(), kp.entity_id())
.expect("startup verifies with the persisted ceremony skew");
let path = scratch.dir().join(OWNER_MEMBERSHIP_FILE);
let mut config: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).expect("read")).expect("parse");
config["verification_skew_secs"] = serde_json::Value::from(999_999u64);
std::fs::write(&path, serde_json::to_vec(&config).expect("ser")).expect("write");
let err = NodeAuthority::open(scratch.dir(), kp.entity_id()).expect_err("over ceiling");
assert!(matches!(err, OrgAuthorityError::CertInvalid(_)));
}
#[cfg(unix)]
#[test]
fn symlinked_authority_files_are_refused() {
let scratch = Scratch::new();
let kp = node_identity();
NodeAuthority::adopt(scratch.dir(), cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect("adopt");
let key_path = scratch.dir().join(OWNER_AUDIENCE_FILE);
let moved = scratch.dir().join("moved-audience.key");
std::fs::rename(&key_path, &moved).expect("move key");
std::os::unix::fs::symlink(&moved, &key_path).expect("plant symlink");
assert!(
NodeAuthority::open(scratch.dir(), kp.entity_id()).is_err(),
"symlinked audience key must refuse"
);
std::fs::remove_file(&key_path).expect("remove link");
std::fs::rename(&moved, &key_path).expect("restore");
NodeAuthority::open(scratch.dir(), kp.entity_id()).expect("regular key opens");
let membership = scratch.dir().join(OWNER_MEMBERSHIP_FILE);
let moved = scratch.dir().join("moved-membership.json");
std::fs::rename(&membership, &moved).expect("move membership");
std::os::unix::fs::symlink(&moved, &membership).expect("plant symlink");
assert!(
NodeAuthority::open(scratch.dir(), kp.entity_id()).is_err(),
"symlinked membership must refuse"
);
}
#[test]
fn audience_codec_round_trips_and_debug_redacts() {
let credential = OwnerAudienceCredential::generate(org().org_id());
let encoded = credential.encode_config();
let decoded = OwnerAudienceCredential::decode_config(&encoded).expect("decode");
assert_eq!(decoded.owner_org, credential.owner_org);
assert_eq!(decoded.audience_handle, credential.audience_handle);
assert_eq!(decoded.discovery_key(), credential.discovery_key());
let mut trailing = encoded.to_vec();
trailing.push(0);
assert!(OwnerAudienceCredential::decode_config(&trailing).is_err());
let debug = format!("{credential:?}");
assert!(debug.contains("[REDACTED]"));
assert!(!debug.contains(&hex::encode(credential.discovery_key())));
}
#[test]
fn audience_codec_binds_the_owning_org() {
let org_a = OrgKeypair::from_bytes([0xA1u8; 32]);
let org_b = OrgKeypair::from_bytes([0xB2u8; 32]);
let a = OwnerAudienceCredential::generate(org_a.org_id());
let b = OwnerAudienceCredential::generate(org_b.org_id());
assert_eq!(a.owner_org, org_a.org_id());
assert_eq!(b.owner_org, org_b.org_id());
let (ea, eb) = (a.encode_config(), b.encode_config());
assert_eq!(&ea[1..33], org_a.org_id().as_bytes());
assert_eq!(&eb[1..33], org_b.org_id().as_bytes());
assert_ne!(&ea[1..33], a.discovery_key());
let v1_shaped = &ea[..1 + 32 + 32];
assert!(
OwnerAudienceCredential::decode_config(v1_shaped).is_err(),
"a pre-binding audience file must be refused, never migrated by \
assuming the current adopter's org",
);
}
fn floors_bundle(member: &EntityId, generation: u32) -> OrgRevocationBundle {
let mut floors = BTreeMap::new();
floors.insert(member.clone(), generation);
OrgRevocationBundle::try_issue(&org(), &floors).expect("issue")
}
#[test]
fn adopt_refuses_to_inherit_another_orgs_audience_key() {
let scratch = Scratch::new();
let kp = node_identity();
let org_a = OrgKeypair::from_bytes([0xA1u8; 32]);
let org_b = OrgKeypair::from_bytes([0xB2u8; 32]);
let cert_a =
OrgMembershipCert::try_issue(&org_a, kp.entity_id().clone(), 1, 3600).expect("issue A");
let authority_a =
NodeAuthority::adopt(scratch.dir(), cert_a, kp.entity_id(), 0, None).expect("adopt A");
let a_key = *authority_a.audience.discovery_key();
let a_handle = authority_a.audience.audience_handle;
drop(authority_a);
std::fs::remove_file(scratch.dir().join(OWNER_MEMBERSHIP_FILE)).expect("remove membership");
let cert_b =
OrgMembershipCert::try_issue(&org_b, kp.entity_id().clone(), 1, 3600).expect("issue B");
let err = NodeAuthority::adopt(scratch.dir(), cert_b, kp.entity_id(), 0, None)
.expect_err("B must not inherit A's audience key");
assert!(
matches!(
err,
OrgAuthorityError::AlreadyOwned { existing, requested }
if existing == org_a.org_id() && requested == org_b.org_id()
),
"expected AlreadyOwned(A -> B), got {err:?}",
);
let reopened = read_audience_checked(&scratch.dir().join(OWNER_AUDIENCE_FILE))
.expect("read audience")
.expect("audience present");
let cred = OwnerAudienceCredential::decode_config(&reopened).expect("decode");
assert_eq!(cred.owner_org, org_a.org_id());
assert_eq!(cred.discovery_key(), &a_key);
assert_eq!(cred.audience_handle, a_handle);
}
#[test]
fn readopt_under_the_same_org_preserves_the_audience_key() {
let scratch = Scratch::new();
let kp = node_identity();
let first = NodeAuthority::adopt(scratch.dir(), cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect("first adopt");
let key = *first.audience.discovery_key();
let handle = first.audience.audience_handle;
drop(first);
let second = NodeAuthority::adopt(scratch.dir(), cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect("same-org re-adopt must succeed");
assert_eq!(
second.audience.discovery_key(),
&key,
"a same-org renewal must NOT rotate the audience key",
);
assert_eq!(second.audience.audience_handle, handle);
}
#[test]
fn adopt_with_floors_refuses_immediately_revoked_cert() {
let scratch = Scratch::new();
let kp = node_identity();
let bundle = floors_bundle(kp.entity_id(), 5);
let err = NodeAuthority::adopt(
scratch.dir(),
cert_for(&kp, 3),
kp.entity_id(),
0,
Some(&bundle),
)
.expect_err("generation 3 under candidate floor 5 must refuse");
assert!(matches!(
err,
OrgAuthorityError::CertBelowFloor {
generation: 3,
floor: 5
}
));
for name in NodeAuthority::file_names() {
assert!(
!scratch.dir().join(name).exists(),
"{name} must not exist after a refused adoption"
);
}
let authority = NodeAuthority::adopt(
scratch.dir(),
cert_for(&kp, 5),
kp.entity_id(),
0,
Some(&bundle),
)
.expect("generation 5 at floor 5 adopts");
assert_eq!(
authority
.revocation
.floor_for(&org().org_id(), kp.entity_id()),
5
);
let reopened = NodeAuthority::open(scratch.dir(), kp.entity_id()).expect("open");
assert_eq!(
reopened
.revocation
.floor_for(&org().org_id(), kp.entity_id()),
5
);
}
#[test]
fn adopt_refuses_foreign_floor_bundle() {
let scratch = Scratch::new();
let kp = node_identity();
let org_b = OrgKeypair::from_bytes([0x99u8; 32]);
let mut floors = BTreeMap::new();
floors.insert(kp.entity_id().clone(), 5u32);
let foreign = OrgRevocationBundle::try_issue(&org_b, &floors).expect("issue");
let err = NodeAuthority::adopt(
scratch.dir(),
cert_for(&kp, 1),
kp.entity_id(),
0,
Some(&foreign),
)
.expect_err("B-signed bundle under A adoption must refuse");
assert!(matches!(err, OrgAuthorityError::ForeignFloorBundle { .. }));
for name in NodeAuthority::file_names() {
assert!(!scratch.dir().join(name).exists());
}
}
#[test]
fn renewal_against_corrupt_audience_leaves_membership_untouched() {
let scratch = Scratch::new();
let kp = node_identity();
NodeAuthority::adopt(scratch.dir(), cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect("adopt");
let m1 = std::fs::read(scratch.dir().join(OWNER_MEMBERSHIP_FILE)).expect("read M1");
std::fs::write(scratch.dir().join(OWNER_AUDIENCE_FILE), [1u8; 10]).expect("corrupt");
let err = NodeAuthority::adopt(scratch.dir(), cert_for(&kp, 2), kp.entity_id(), 0, None)
.expect_err("renewal over corrupt audience must refuse");
assert!(matches!(err, OrgAuthorityError::CorruptFile { .. }));
let after = std::fs::read(scratch.dir().join(OWNER_MEMBERSHIP_FILE)).expect("read");
assert_eq!(after, m1, "failed renewal must not advertise M2");
}
#[cfg(unix)]
#[test]
fn permissive_audience_key_refuses_open_and_readopt() {
use std::os::unix::fs::PermissionsExt;
let scratch = Scratch::new();
let kp = node_identity();
NodeAuthority::adopt(scratch.dir(), cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect("adopt");
let key_path = scratch.dir().join(OWNER_AUDIENCE_FILE);
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o644))
.expect("chmod 644");
let err = NodeAuthority::open(scratch.dir(), kp.entity_id())
.expect_err("permissive key must refuse startup");
assert!(matches!(
err,
OrgAuthorityError::PermissiveAudienceFile { mode: 0o644, .. }
));
let err = NodeAuthority::adopt(scratch.dir(), cert_for(&kp, 2), kp.entity_id(), 0, None)
.expect_err("re-adopt must not silently preserve a permissive key");
assert!(matches!(
err,
OrgAuthorityError::PermissiveAudienceFile { .. }
));
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))
.expect("chmod 600");
NodeAuthority::open(scratch.dir(), kp.entity_id()).expect("open after tighten");
}
#[cfg(unix)]
#[test]
fn pre_created_permissive_temps_cannot_weaken_the_final_key() {
use std::os::unix::fs::PermissionsExt;
let scratch = Scratch::new();
let kp = node_identity();
let pid = std::process::id();
for name in [
format!("owner-audience.tmp.{pid}"),
format!("owner-audience.key.tmp.{pid}"),
format!("owner-audience.key.tmp.{pid}.0.00000000"),
] {
let p = scratch.dir().join(name);
std::fs::write(&p, b"attacker").expect("pre-create");
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644))
.expect("chmod 644");
}
NodeAuthority::adopt(scratch.dir(), cert_for(&kp, 1), kp.entity_id(), 0, None)
.expect("adopt");
let mode = std::fs::metadata(scratch.dir().join(OWNER_AUDIENCE_FILE))
.expect("metadata")
.permissions()
.mode();
assert_eq!(
mode & 0o077,
0,
"final audience key must be owner-only, got {mode:o}"
);
NodeAuthority::open(scratch.dir(), kp.entity_id()).expect("open");
}
}