provider_credential.rs (18003B)
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 MycBootstrapProfileV1, MycEncryptedIdentityEnvelopeErrorKind, MycProviderBinding, 10 MycProviderKind, MycRuntimeContext, MycWrappingCredential, 11 provider_envelope::load_resolved_wrapping_credential, 12 }; 13 14 /// Exact fixed wrapping-credential artifact length. 15 pub const MYC_WRAPPING_CREDENTIAL_ARTIFACT_BYTES: usize = 32; 16 /// Exact Myc wrapping-credential resolution contract version. 17 pub const MYC_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 MycCredentialResolutionErrorKind { 22 InvalidBinding, 23 UnsupportedProfile, 24 InvalidReference, 25 MissingCredential, 26 InsecureSecretsRoot, 27 InsecureCredential, 28 InvalidCredential, 29 Io, 30 UnsupportedPlatform, 31 } 32 33 impl MycCredentialResolutionErrorKind { 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 MycCredentialResolutionError { 68 kind: MycCredentialResolutionErrorKind, 69 } 70 71 impl MycCredentialResolutionError { 72 /// Returns the stable failure kind. 73 #[must_use] 74 pub const fn kind(self) -> MycCredentialResolutionErrorKind { 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 MycCredentialResolutionError { 86 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 87 formatter 88 .debug_struct("MycCredentialResolutionError") 89 .field("kind", &self.kind) 90 .finish() 91 } 92 } 93 94 impl fmt::Display for MycCredentialResolutionError { 95 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 96 formatter.write_str(self.kind.message()) 97 } 98 } 99 100 impl Error for MycCredentialResolutionError {} 101 102 const fn resolution_error(kind: MycCredentialResolutionErrorKind) -> MycCredentialResolutionError { 103 MycCredentialResolutionError { 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_myc_wrapping_credential( 112 runtime: &MycRuntimeContext, 113 binding: &MycProviderBinding, 114 ) -> Result<MycWrappingCredential, MycCredentialResolutionError> { 115 if !matches!( 116 runtime.profile(), 117 MycBootstrapProfileV1::ServiceHost | MycBootstrapProfileV1::RepoLocal 118 ) { 119 return Err(resolution_error( 120 MycCredentialResolutionErrorKind::UnsupportedProfile, 121 )); 122 } 123 if binding.kind() != MycProviderKind::EncryptedFile { 124 return Err(resolution_error( 125 MycCredentialResolutionErrorKind::InvalidBinding, 126 )); 127 } 128 let reference = binding 129 .credential_reference() 130 .ok_or_else(|| resolution_error(MycCredentialResolutionErrorKind::InvalidBinding))?; 131 let name = ServiceCredentialArtifactName::new(reference.as_str()) 132 .map_err(|_| resolution_error(MycCredentialResolutionErrorKind::InvalidReference))?; 133 let path = service_credential_artifact_path(runtime.context().paths(), &name); 134 if binding.encrypted_envelope_path() == Some(path.as_path()) { 135 return Err(resolution_error( 136 MycCredentialResolutionErrorKind::InvalidBinding, 137 )); 138 } 139 load_resolved_wrapping_credential(&path).map_err(|error| { 140 let kind = match error.kind() { 141 MycEncryptedIdentityEnvelopeErrorKind::InvalidPath 142 | MycEncryptedIdentityEnvelopeErrorKind::InvalidBinding => { 143 MycCredentialResolutionErrorKind::InvalidBinding 144 } 145 MycEncryptedIdentityEnvelopeErrorKind::MissingEnvelope => { 146 MycCredentialResolutionErrorKind::MissingCredential 147 } 148 MycEncryptedIdentityEnvelopeErrorKind::InsecureParent => { 149 MycCredentialResolutionErrorKind::InsecureSecretsRoot 150 } 151 MycEncryptedIdentityEnvelopeErrorKind::InsecureArtifact => { 152 MycCredentialResolutionErrorKind::InsecureCredential 153 } 154 MycEncryptedIdentityEnvelopeErrorKind::InvalidCredential => { 155 MycCredentialResolutionErrorKind::InvalidCredential 156 } 157 MycEncryptedIdentityEnvelopeErrorKind::UnsupportedPlatform => { 158 MycCredentialResolutionErrorKind::UnsupportedPlatform 159 } 160 MycEncryptedIdentityEnvelopeErrorKind::InvalidProvisioningMaterial 161 | MycEncryptedIdentityEnvelopeErrorKind::AlreadyExists 162 | MycEncryptedIdentityEnvelopeErrorKind::UnsupportedEnvelopeVersion 163 | MycEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope 164 | MycEncryptedIdentityEnvelopeErrorKind::WrongCredential 165 | MycEncryptedIdentityEnvelopeErrorKind::IdentityMismatch 166 | MycEncryptedIdentityEnvelopeErrorKind::Io => MycCredentialResolutionErrorKind::Io, 167 }; 168 resolution_error(kind) 169 }) 170 } 171 172 #[cfg(test)] 173 mod tests { 174 #[cfg(any(target_os = "linux", target_os = "macos"))] 175 use std::fs; 176 #[cfg(any(target_os = "linux", target_os = "macos"))] 177 use std::os::unix::fs::{PermissionsExt, symlink}; 178 use std::path::{Path, PathBuf}; 179 180 use nostr::{Keys, SecretKey}; 181 use sha2::{Digest, Sha256}; 182 183 use crate::{ 184 MycConfigProfile, MycProviderRole, RadrootsHostEnvironment, RadrootsPathResolver, 185 RadrootsPlatform, parse_myc_cli_v1_from, parse_myc_config_v1, resolve_myc_runtime_context, 186 }; 187 188 use super::*; 189 190 const CONFIG: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); 191 192 fn bytes(label: &str) -> [u8; 32] { 193 Sha256::digest(label.as_bytes()).into() 194 } 195 196 fn runtime(root: &Path, profile: &str) -> MycRuntimeContext { 197 let root = root.to_str().expect("UTF-8 test root"); 198 let arguments = if profile == "repo-local" { 199 vec![ 200 "myc", 201 "--profile", 202 profile, 203 "--instance", 204 "primary", 205 "--repo-local-root", 206 root, 207 "run", 208 ] 209 } else { 210 vec!["myc", "--profile", profile, "--instance", "primary", "run"] 211 }; 212 let invocation = parse_myc_cli_v1_from(arguments).expect("test invocation"); 213 let environment = if profile == "interactive" { 214 RadrootsHostEnvironment { 215 home_dir: Some(PathBuf::from(root)), 216 xdg_config_home: Some(PathBuf::from(root).join("config")), 217 xdg_data_home: Some(PathBuf::from(root).join("data")), 218 xdg_state_home: Some(PathBuf::from(root).join("state")), 219 xdg_cache_home: Some(PathBuf::from(root).join("cache")), 220 xdg_runtime_dir: Some(PathBuf::from(root).join("run")), 221 ..RadrootsHostEnvironment::default() 222 } 223 } else { 224 RadrootsHostEnvironment::default() 225 }; 226 resolve_myc_runtime_context( 227 &RadrootsPathResolver::new(RadrootsPlatform::Linux, environment), 228 &invocation, 229 ) 230 .expect("runtime context") 231 } 232 233 fn binding(envelope_path: &Path) -> MycProviderBinding { 234 let mut identity = bytes("radroots.myc.credential-test.identity.v1"); 235 while SecretKey::from_slice(&identity).is_err() { 236 identity = Sha256::digest(identity).into(); 237 } 238 let public_key = Keys::new(SecretKey::from_slice(&identity).expect("identity")) 239 .public_key() 240 .to_hex(); 241 let source = CONFIG 242 .replace( 243 "/var/lib/radroots/services/myc/primary/secrets/transport.identity.ncrypt", 244 envelope_path.to_str().expect("UTF-8 envelope path"), 245 ) 246 .replace( 247 "4444444444444444444444444444444444444444444444444444444444444444", 248 &public_key, 249 ); 250 parse_myc_config_v1(source.as_bytes(), MycConfigProfile::RepoLocal) 251 .expect("configuration") 252 .provider_contract() 253 .binding(MycProviderRole::Transport) 254 .expect("transport binding") 255 .clone() 256 } 257 258 #[cfg(any(target_os = "linux", target_os = "macos"))] 259 fn prepare_credential(runtime: &MycRuntimeContext, name: &str, contents: &[u8]) -> PathBuf { 260 let root = runtime.context().paths().secrets(); 261 fs::create_dir_all(root).expect("secrets root"); 262 fs::set_permissions(root, fs::Permissions::from_mode(0o700)).expect("secrets mode"); 263 let path = root.join(name); 264 fs::write(&path, contents).expect("credential artifact"); 265 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("credential mode"); 266 path 267 } 268 269 #[cfg(any(target_os = "linux", target_os = "macos"))] 270 #[test] 271 fn canonical_existing_artifact_resolves_without_path_or_value_exposure() { 272 let directory = tempfile::tempdir().expect("test root"); 273 let runtime = runtime(directory.path(), "repo-local"); 274 let envelope_parent = directory.path().join("envelopes"); 275 fs::create_dir(&envelope_parent).expect("envelope parent"); 276 fs::set_permissions(&envelope_parent, fs::Permissions::from_mode(0o700)) 277 .expect("envelope parent mode"); 278 let binding = binding(&envelope_parent.join("transport.identity.ncrypt")); 279 let credential_bytes = bytes("radroots.myc.credential-test.wrapping.v1"); 280 let path = prepare_credential( 281 &runtime, 282 binding 283 .credential_reference() 284 .expect("credential reference") 285 .as_str(), 286 &credential_bytes, 287 ); 288 289 let credential = 290 resolve_myc_wrapping_credential(&runtime, &binding).expect("credential resolution"); 291 assert_eq!( 292 format!("{credential:?}"), 293 "MycWrappingCredential([redacted])" 294 ); 295 assert_eq!( 296 path, 297 runtime 298 .context() 299 .paths() 300 .secrets() 301 .join("transport_wrapping_key") 302 ); 303 assert_eq!( 304 fs::read(&path).expect("credential unchanged"), 305 credential_bytes 306 ); 307 assert_eq!( 308 fs::metadata(&path) 309 .expect("credential metadata") 310 .permissions() 311 .mode() 312 & 0o777, 313 0o600 314 ); 315 fs::set_permissions(&path, fs::Permissions::from_mode(0o400)) 316 .expect("read-only credential mode"); 317 fs::set_permissions( 318 runtime.context().paths().secrets(), 319 fs::Permissions::from_mode(0o500), 320 ) 321 .expect("read-only secrets root mode"); 322 resolve_myc_wrapping_credential(&runtime, &binding) 323 .expect("owner-read-only artifact and secrets root"); 324 } 325 326 #[cfg(any(target_os = "linux", target_os = "macos"))] 327 #[test] 328 fn missing_adjacent_short_long_zero_and_insecure_artifacts_fail_closed() { 329 let directory = tempfile::tempdir().expect("test root"); 330 let runtime = runtime(directory.path(), "repo-local"); 331 let envelope_parent = directory.path().join("envelopes"); 332 fs::create_dir(&envelope_parent).expect("envelope parent"); 333 fs::set_permissions(&envelope_parent, fs::Permissions::from_mode(0o700)) 334 .expect("envelope parent mode"); 335 let envelope_path = envelope_parent.join("transport.identity.ncrypt"); 336 let binding = binding(&envelope_path); 337 let adjacent = envelope_parent.join("transport.identity.key"); 338 fs::write(&adjacent, bytes("adjacent key")).expect("adjacent file"); 339 fs::create_dir_all(runtime.context().paths().secrets()).expect("secrets root"); 340 fs::set_permissions( 341 runtime.context().paths().secrets(), 342 fs::Permissions::from_mode(0o700), 343 ) 344 .expect("secrets mode"); 345 assert_eq!( 346 resolve_myc_wrapping_credential(&runtime, &binding) 347 .expect_err("canonical credential is missing") 348 .kind(), 349 MycCredentialResolutionErrorKind::MissingCredential 350 ); 351 352 let reference = binding.credential_reference().expect("reference").as_str(); 353 let path = prepare_credential(&runtime, reference, &[1; 31]); 354 assert_eq!( 355 resolve_myc_wrapping_credential(&runtime, &binding) 356 .expect_err("short") 357 .kind(), 358 MycCredentialResolutionErrorKind::InsecureCredential 359 ); 360 fs::write(&path, [1; 33]).expect("long"); 361 assert_eq!( 362 resolve_myc_wrapping_credential(&runtime, &binding) 363 .expect_err("long") 364 .kind(), 365 MycCredentialResolutionErrorKind::InsecureCredential 366 ); 367 fs::write(&path, [0; 32]).expect("zero"); 368 assert_eq!( 369 resolve_myc_wrapping_credential(&runtime, &binding) 370 .expect_err("zero") 371 .kind(), 372 MycCredentialResolutionErrorKind::InvalidCredential 373 ); 374 fs::write(&path, [1; 32]).expect("valid length"); 375 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("insecure mode"); 376 assert_eq!( 377 resolve_myc_wrapping_credential(&runtime, &binding) 378 .expect_err("mode") 379 .kind(), 380 MycCredentialResolutionErrorKind::InsecureCredential 381 ); 382 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("restore mode"); 383 let second_link = runtime.context().paths().secrets().join("second-link"); 384 fs::hard_link(&path, &second_link).expect("hard link"); 385 assert_eq!( 386 resolve_myc_wrapping_credential(&runtime, &binding) 387 .expect_err("hard link") 388 .kind(), 389 MycCredentialResolutionErrorKind::InsecureCredential 390 ); 391 fs::remove_file(&second_link).expect("remove hard link"); 392 fs::remove_file(&path).expect("remove credential"); 393 symlink(&adjacent, &path).expect("credential symlink"); 394 assert_eq!( 395 resolve_myc_wrapping_credential(&runtime, &binding) 396 .expect_err("symlink") 397 .kind(), 398 MycCredentialResolutionErrorKind::InsecureCredential 399 ); 400 } 401 402 #[test] 403 fn unsupported_interactive_profile_and_errors_are_source_free() { 404 let directory = tempfile::tempdir().expect("test root"); 405 let runtime = runtime(directory.path(), "interactive"); 406 let binding = binding(&directory.path().join("transport.identity.ncrypt")); 407 let error = 408 resolve_myc_wrapping_credential(&runtime, &binding).expect_err("interactive profile"); 409 assert_eq!( 410 error.kind(), 411 MycCredentialResolutionErrorKind::UnsupportedProfile 412 ); 413 assert!(Error::source(&error).is_none()); 414 let rendered = format!("{error} {error:?}"); 415 assert!(!rendered.contains(directory.path().to_string_lossy().as_ref())); 416 for kind in [ 417 MycCredentialResolutionErrorKind::InvalidBinding, 418 MycCredentialResolutionErrorKind::UnsupportedProfile, 419 MycCredentialResolutionErrorKind::InvalidReference, 420 MycCredentialResolutionErrorKind::MissingCredential, 421 MycCredentialResolutionErrorKind::InsecureSecretsRoot, 422 MycCredentialResolutionErrorKind::InsecureCredential, 423 MycCredentialResolutionErrorKind::InvalidCredential, 424 MycCredentialResolutionErrorKind::Io, 425 MycCredentialResolutionErrorKind::UnsupportedPlatform, 426 ] { 427 let error = resolution_error(kind); 428 assert!(!error.code().is_empty()); 429 assert!(Error::source(&error).is_none()); 430 } 431 } 432 }