identity_credential.rs (19463B)
1 //! Canonical wrapping-credential artifact resolution. 2 3 use core::fmt; 4 use std::error::Error; 5 6 use radroots_runtime_paths::{ServiceCredentialArtifactName, service_credential_artifact_path}; 7 8 use crate::{ 9 RhiBootstrapProfileV1, RhiEncryptedIdentityEnvelopeErrorKind, RhiIdentityEnvelopeBinding, 10 RhiIdentityProviderKind, RhiRuntimeContext, RhiWrappingCredential, 11 identity_envelope::load_resolved_wrapping_credential, 12 }; 13 14 /// Exact fixed wrapping-credential artifact length. 15 pub const RHI_WRAPPING_CREDENTIAL_ARTIFACT_BYTES: usize = 32; 16 /// Exact Rhi wrapping-credential resolution contract version. 17 pub const RHI_WRAPPING_CREDENTIAL_CONTRACT_VERSION: u32 = 1; 18 19 /// Stable source-free credential-resolution failure classification. 20 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 21 pub enum RhiCredentialResolutionErrorKind { 22 InvalidBinding, 23 UnsupportedProfile, 24 InvalidReference, 25 MissingCredential, 26 InsecureSecretsRoot, 27 InsecureCredential, 28 InvalidCredential, 29 Io, 30 UnsupportedPlatform, 31 } 32 33 impl RhiCredentialResolutionErrorKind { 34 /// Returns the stable machine-facing safe code. 35 #[must_use] 36 pub const fn code(self) -> &'static str { 37 match self { 38 Self::InvalidBinding => "provider_credential_binding_invalid", 39 Self::UnsupportedProfile => "provider_credential_profile_unsupported", 40 Self::InvalidReference => "provider_credential_reference_invalid", 41 Self::MissingCredential => "provider_credential_missing", 42 Self::InsecureSecretsRoot => "provider_credential_root_insecure", 43 Self::InsecureCredential => "provider_credential_artifact_insecure", 44 Self::InvalidCredential => "provider_credential_material_invalid", 45 Self::Io => "provider_credential_io_failed", 46 Self::UnsupportedPlatform => "provider_credential_platform_unsupported", 47 } 48 } 49 50 const fn message(self) -> &'static str { 51 match self { 52 Self::InvalidBinding => "provider credential binding is invalid", 53 Self::UnsupportedProfile => "provider credential profile is unsupported", 54 Self::InvalidReference => "provider credential reference is invalid", 55 Self::MissingCredential => "provider credential is missing", 56 Self::InsecureSecretsRoot => "provider credential root is insecure", 57 Self::InsecureCredential => "provider credential artifact is insecure", 58 Self::InvalidCredential => "provider credential material is invalid", 59 Self::Io => "provider credential storage failed", 60 Self::UnsupportedPlatform => "provider credential storage is unsupported", 61 } 62 } 63 } 64 65 /// One source-free wrapping-credential resolution failure. 66 #[derive(Clone, Copy, PartialEq, Eq)] 67 pub struct RhiCredentialResolutionError { 68 kind: RhiCredentialResolutionErrorKind, 69 } 70 71 impl RhiCredentialResolutionError { 72 /// Returns the stable failure kind. 73 #[must_use] 74 pub const fn kind(self) -> RhiCredentialResolutionErrorKind { 75 self.kind 76 } 77 78 /// Returns the stable machine-facing safe code. 79 #[must_use] 80 pub const fn code(self) -> &'static str { 81 self.kind.code() 82 } 83 } 84 85 impl fmt::Debug for RhiCredentialResolutionError { 86 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 87 formatter 88 .debug_struct("RhiCredentialResolutionError") 89 .field("kind", &self.kind) 90 .finish() 91 } 92 } 93 94 impl fmt::Display for RhiCredentialResolutionError { 95 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 96 formatter.write_str(self.kind.message()) 97 } 98 } 99 100 impl Error for RhiCredentialResolutionError {} 101 102 const fn resolution_error(kind: RhiCredentialResolutionErrorKind) -> RhiCredentialResolutionError { 103 RhiCredentialResolutionError { kind } 104 } 105 106 /// Resolves one existing wrapping credential from the canonical instance secrets root. 107 /// 108 /// The caller supplies no path or credential bytes. Production deployment and 109 /// repo-local offline tooling provision the fixed artifact externally; this 110 /// operation is read-only and never creates a credential or parent directory. 111 pub fn resolve_rhi_wrapping_credential( 112 runtime: &RhiRuntimeContext, 113 binding: &RhiIdentityEnvelopeBinding, 114 ) -> Result<RhiWrappingCredential, RhiCredentialResolutionError> { 115 if !matches!( 116 runtime.profile(), 117 RhiBootstrapProfileV1::ServiceHost | RhiBootstrapProfileV1::RepoLocal 118 ) { 119 return Err(resolution_error( 120 RhiCredentialResolutionErrorKind::UnsupportedProfile, 121 )); 122 } 123 if binding.kind() != RhiIdentityProviderKind::EncryptedFile { 124 return Err(resolution_error( 125 RhiCredentialResolutionErrorKind::InvalidBinding, 126 )); 127 } 128 if !binding.matches_runtime(runtime) { 129 return Err(resolution_error( 130 RhiCredentialResolutionErrorKind::InvalidBinding, 131 )); 132 } 133 let reference = binding 134 .credential_reference() 135 .ok_or_else(|| resolution_error(RhiCredentialResolutionErrorKind::InvalidBinding))?; 136 let name = ServiceCredentialArtifactName::new(reference.as_str()) 137 .map_err(|_| resolution_error(RhiCredentialResolutionErrorKind::InvalidReference))?; 138 let path = service_credential_artifact_path(runtime.context().paths(), &name); 139 if binding.encrypted_envelope_path() == Some(path.as_path()) { 140 return Err(resolution_error( 141 RhiCredentialResolutionErrorKind::InvalidBinding, 142 )); 143 } 144 load_resolved_wrapping_credential(&path).map_err(|error| { 145 let kind = match error.kind() { 146 RhiEncryptedIdentityEnvelopeErrorKind::InvalidPath 147 | RhiEncryptedIdentityEnvelopeErrorKind::InvalidBinding => { 148 RhiCredentialResolutionErrorKind::InvalidBinding 149 } 150 RhiEncryptedIdentityEnvelopeErrorKind::MissingEnvelope => { 151 RhiCredentialResolutionErrorKind::MissingCredential 152 } 153 RhiEncryptedIdentityEnvelopeErrorKind::InsecureParent => { 154 RhiCredentialResolutionErrorKind::InsecureSecretsRoot 155 } 156 RhiEncryptedIdentityEnvelopeErrorKind::InsecureArtifact => { 157 RhiCredentialResolutionErrorKind::InsecureCredential 158 } 159 RhiEncryptedIdentityEnvelopeErrorKind::InvalidCredential => { 160 RhiCredentialResolutionErrorKind::InvalidCredential 161 } 162 RhiEncryptedIdentityEnvelopeErrorKind::UnsupportedPlatform => { 163 RhiCredentialResolutionErrorKind::UnsupportedPlatform 164 } 165 RhiEncryptedIdentityEnvelopeErrorKind::InvalidProvisioningMaterial 166 | RhiEncryptedIdentityEnvelopeErrorKind::AlreadyExists 167 | RhiEncryptedIdentityEnvelopeErrorKind::UnsupportedEnvelopeVersion 168 | RhiEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope 169 | RhiEncryptedIdentityEnvelopeErrorKind::WrongCredential 170 | RhiEncryptedIdentityEnvelopeErrorKind::IdentityMismatch 171 | RhiEncryptedIdentityEnvelopeErrorKind::Io => RhiCredentialResolutionErrorKind::Io, 172 }; 173 resolution_error(kind) 174 }) 175 } 176 177 #[cfg(test)] 178 mod tests { 179 #[cfg(any(target_os = "linux", target_os = "macos"))] 180 use std::fs; 181 #[cfg(any(target_os = "linux", target_os = "macos"))] 182 use std::os::unix::fs::{PermissionsExt, symlink}; 183 use std::path::{Path, PathBuf}; 184 185 use nostr::{Keys, SecretKey}; 186 use sha2::{Digest, Sha256}; 187 188 use radroots_storage::event::SourceGeneration; 189 190 use crate::{ 191 RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, RhiConfigProfile, 192 RhiStateMetadata, parse_rhi_cli_v1_from, parse_rhi_config_v1, resolve_rhi_runtime_context, 193 }; 194 195 use super::*; 196 197 const CONFIG: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); 198 199 fn bytes(label: &str) -> [u8; 32] { 200 Sha256::digest(label.as_bytes()).into() 201 } 202 203 fn runtime(root: &Path, profile: &str, instance: &str) -> RhiRuntimeContext { 204 let root = root.to_str().expect("UTF-8 test root"); 205 let arguments = if profile == "repo-local" { 206 vec![ 207 "rhi", 208 "--profile", 209 profile, 210 "--instance", 211 instance, 212 "--repo-local-root", 213 root, 214 "run", 215 ] 216 } else { 217 vec!["rhi", "--profile", profile, "--instance", instance, "run"] 218 }; 219 let invocation = parse_rhi_cli_v1_from(arguments).expect("test invocation"); 220 let environment = if profile == "interactive" { 221 RadrootsHostEnvironment { 222 home_dir: Some(PathBuf::from(root)), 223 xdg_config_home: Some(PathBuf::from(root).join("config")), 224 xdg_data_home: Some(PathBuf::from(root).join("data")), 225 xdg_state_home: Some(PathBuf::from(root).join("state")), 226 xdg_cache_home: Some(PathBuf::from(root).join("cache")), 227 xdg_runtime_dir: Some(PathBuf::from(root).join("run")), 228 ..RadrootsHostEnvironment::default() 229 } 230 } else { 231 RadrootsHostEnvironment::default() 232 }; 233 resolve_rhi_runtime_context( 234 &RadrootsPathResolver::new(RadrootsPlatform::Linux, environment), 235 &invocation, 236 ) 237 .expect("runtime context") 238 } 239 240 fn binding(runtime: &RhiRuntimeContext, envelope_path: &Path) -> RhiIdentityEnvelopeBinding { 241 let mut identity = bytes("radroots.rhi.credential-test.identity.v1"); 242 while SecretKey::from_slice(&identity).is_err() { 243 identity = Sha256::digest(identity).into(); 244 } 245 let public_key = Keys::new(SecretKey::from_slice(&identity).expect("identity")) 246 .public_key() 247 .to_hex(); 248 let source = CONFIG 249 .replace( 250 "/var/lib/radroots/services/rhi/default/secrets/service.identity.ncrypt", 251 envelope_path.to_str().expect("UTF-8 envelope path"), 252 ) 253 .replace(&"2".repeat(64), &public_key); 254 let configuration = parse_rhi_config_v1(source.as_bytes(), RhiConfigProfile::RepoLocal) 255 .expect("configuration"); 256 let metadata = RhiStateMetadata::new( 257 runtime, 258 &configuration, 259 SourceGeneration::new([0x6b; 32]).expect("source generation"), 260 1, 261 ) 262 .expect("state metadata"); 263 RhiIdentityEnvelopeBinding::from_configuration(&configuration, &metadata) 264 .expect("identity binding") 265 } 266 267 #[cfg(any(target_os = "linux", target_os = "macos"))] 268 fn prepare_credential(runtime: &RhiRuntimeContext, name: &str, contents: &[u8]) -> PathBuf { 269 let root = runtime.context().paths().secrets(); 270 fs::create_dir_all(root).expect("secrets root"); 271 fs::set_permissions(root, fs::Permissions::from_mode(0o700)).expect("secrets mode"); 272 let path = root.join(name); 273 fs::write(&path, contents).expect("credential artifact"); 274 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("credential mode"); 275 path 276 } 277 278 #[cfg(any(target_os = "linux", target_os = "macos"))] 279 #[test] 280 fn canonical_existing_artifact_resolves_without_path_or_value_exposure() { 281 let directory = tempfile::tempdir().expect("test root"); 282 let runtime = runtime(directory.path(), "repo-local", "primary"); 283 let envelope_parent = directory.path().join("envelopes"); 284 fs::create_dir(&envelope_parent).expect("envelope parent"); 285 fs::set_permissions(&envelope_parent, fs::Permissions::from_mode(0o700)) 286 .expect("envelope parent mode"); 287 let binding = binding(&runtime, &envelope_parent.join("service.identity.ncrypt")); 288 let credential_bytes = bytes("radroots.rhi.credential-test.wrapping.v1"); 289 let path = prepare_credential( 290 &runtime, 291 binding 292 .credential_reference() 293 .expect("credential reference") 294 .as_str(), 295 &credential_bytes, 296 ); 297 298 let credential = 299 resolve_rhi_wrapping_credential(&runtime, &binding).expect("credential resolution"); 300 assert_eq!( 301 format!("{credential:?}"), 302 "RhiWrappingCredential([redacted])" 303 ); 304 assert_eq!( 305 path, 306 runtime 307 .context() 308 .paths() 309 .secrets() 310 .join("service_wrapping_key") 311 ); 312 assert_eq!( 313 fs::read(&path).expect("credential unchanged"), 314 credential_bytes 315 ); 316 assert_eq!( 317 fs::metadata(&path) 318 .expect("credential metadata") 319 .permissions() 320 .mode() 321 & 0o777, 322 0o600 323 ); 324 fs::set_permissions(&path, fs::Permissions::from_mode(0o400)) 325 .expect("read-only credential mode"); 326 fs::set_permissions( 327 runtime.context().paths().secrets(), 328 fs::Permissions::from_mode(0o500), 329 ) 330 .expect("read-only secrets root mode"); 331 resolve_rhi_wrapping_credential(&runtime, &binding) 332 .expect("owner-read-only artifact and secrets root"); 333 } 334 335 #[cfg(any(target_os = "linux", target_os = "macos"))] 336 #[test] 337 fn binding_cannot_be_reused_for_another_instance() { 338 let directory = tempfile::tempdir().expect("test root"); 339 let primary = runtime(directory.path(), "repo-local", "primary"); 340 let secondary = runtime(directory.path(), "repo-local", "secondary"); 341 let binding = binding(&primary, &directory.path().join("service.identity.ncrypt")); 342 prepare_credential( 343 &secondary, 344 "service_wrapping_key", 345 &bytes("radroots.rhi.credential-test.secondary.v1"), 346 ); 347 348 assert_eq!( 349 resolve_rhi_wrapping_credential(&secondary, &binding) 350 .expect_err("binding is tied to the primary runtime") 351 .kind(), 352 RhiCredentialResolutionErrorKind::InvalidBinding 353 ); 354 } 355 356 #[cfg(any(target_os = "linux", target_os = "macos"))] 357 #[test] 358 fn missing_adjacent_short_long_zero_and_insecure_artifacts_fail_closed() { 359 let directory = tempfile::tempdir().expect("test root"); 360 let runtime = runtime(directory.path(), "repo-local", "primary"); 361 let envelope_parent = directory.path().join("envelopes"); 362 fs::create_dir(&envelope_parent).expect("envelope parent"); 363 fs::set_permissions(&envelope_parent, fs::Permissions::from_mode(0o700)) 364 .expect("envelope parent mode"); 365 let envelope_path = envelope_parent.join("service.identity.ncrypt"); 366 let binding = binding(&runtime, &envelope_path); 367 let adjacent = envelope_parent.join("service.identity.key"); 368 fs::write(&adjacent, bytes("adjacent key")).expect("adjacent file"); 369 fs::create_dir_all(runtime.context().paths().secrets()).expect("secrets root"); 370 fs::set_permissions( 371 runtime.context().paths().secrets(), 372 fs::Permissions::from_mode(0o700), 373 ) 374 .expect("secrets mode"); 375 assert_eq!( 376 resolve_rhi_wrapping_credential(&runtime, &binding) 377 .expect_err("canonical credential is missing") 378 .kind(), 379 RhiCredentialResolutionErrorKind::MissingCredential 380 ); 381 382 let reference = binding.credential_reference().expect("reference").as_str(); 383 let path = prepare_credential(&runtime, reference, &[1; 31]); 384 assert_eq!( 385 resolve_rhi_wrapping_credential(&runtime, &binding) 386 .expect_err("short") 387 .kind(), 388 RhiCredentialResolutionErrorKind::InsecureCredential 389 ); 390 fs::write(&path, [1; 33]).expect("long"); 391 assert_eq!( 392 resolve_rhi_wrapping_credential(&runtime, &binding) 393 .expect_err("long") 394 .kind(), 395 RhiCredentialResolutionErrorKind::InsecureCredential 396 ); 397 fs::write(&path, [0; 32]).expect("zero"); 398 assert_eq!( 399 resolve_rhi_wrapping_credential(&runtime, &binding) 400 .expect_err("zero") 401 .kind(), 402 RhiCredentialResolutionErrorKind::InvalidCredential 403 ); 404 fs::write(&path, [1; 32]).expect("valid length"); 405 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("insecure mode"); 406 assert_eq!( 407 resolve_rhi_wrapping_credential(&runtime, &binding) 408 .expect_err("mode") 409 .kind(), 410 RhiCredentialResolutionErrorKind::InsecureCredential 411 ); 412 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("restore mode"); 413 let second_link = runtime.context().paths().secrets().join("second-link"); 414 fs::hard_link(&path, &second_link).expect("hard link"); 415 assert_eq!( 416 resolve_rhi_wrapping_credential(&runtime, &binding) 417 .expect_err("hard link") 418 .kind(), 419 RhiCredentialResolutionErrorKind::InsecureCredential 420 ); 421 fs::remove_file(&second_link).expect("remove hard link"); 422 fs::remove_file(&path).expect("remove credential"); 423 symlink(&adjacent, &path).expect("credential symlink"); 424 assert_eq!( 425 resolve_rhi_wrapping_credential(&runtime, &binding) 426 .expect_err("symlink") 427 .kind(), 428 RhiCredentialResolutionErrorKind::InsecureCredential 429 ); 430 } 431 432 #[test] 433 fn unsupported_interactive_profile_and_errors_are_source_free() { 434 let directory = tempfile::tempdir().expect("test root"); 435 let bound_runtime = runtime(directory.path(), "repo-local", "primary"); 436 let runtime = runtime(directory.path(), "interactive", "primary"); 437 let binding = binding( 438 &bound_runtime, 439 &directory.path().join("service.identity.ncrypt"), 440 ); 441 let error = 442 resolve_rhi_wrapping_credential(&runtime, &binding).expect_err("interactive profile"); 443 assert_eq!( 444 error.kind(), 445 RhiCredentialResolutionErrorKind::UnsupportedProfile 446 ); 447 assert!(Error::source(&error).is_none()); 448 let rendered = format!("{error} {error:?}"); 449 assert!(!rendered.contains(directory.path().to_string_lossy().as_ref())); 450 for kind in [ 451 RhiCredentialResolutionErrorKind::InvalidBinding, 452 RhiCredentialResolutionErrorKind::UnsupportedProfile, 453 RhiCredentialResolutionErrorKind::InvalidReference, 454 RhiCredentialResolutionErrorKind::MissingCredential, 455 RhiCredentialResolutionErrorKind::InsecureSecretsRoot, 456 RhiCredentialResolutionErrorKind::InsecureCredential, 457 RhiCredentialResolutionErrorKind::InvalidCredential, 458 RhiCredentialResolutionErrorKind::Io, 459 RhiCredentialResolutionErrorKind::UnsupportedPlatform, 460 ] { 461 let error = resolution_error(kind); 462 assert!(!error.code().is_empty()); 463 assert!(Error::source(&error).is_none()); 464 } 465 } 466 }