services_hardening_runtime_foundation.rs (13769B)
1 #![forbid(unsafe_code)] 2 #![cfg(any(target_os = "linux", target_os = "macos"))] 3 4 use std::{ 5 fs, 6 os::unix::fs::PermissionsExt, 7 path::Path, 8 sync::{ 9 Arc, 10 atomic::{AtomicUsize, Ordering}, 11 }, 12 }; 13 14 use nostr::{Keys, SecretKey}; 15 use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity}; 16 use radroots_storage::event::SourceGeneration; 17 use radroots_transport::{ 18 BoxFuture, BoxSubscription, DeliveryReceipt, DeliveryRequest, Error as TransportError, 19 EventSink, EventSource, EventSubscriber, FetchPage, FetchRequest, SinkFailure, SinkStatus, 20 SourceStatus, SubscriptionRequest, 21 }; 22 use rhi::{ 23 RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, RhiConfigDocumentV1, 24 RhiConfigProfile, RhiEncryptedIdentityProvisioningMaterial, RhiIdentityCredentialAdapters, 25 RhiIdentityEnvelopeBinding, RhiRuntimeAdapters, RhiRuntimeFoundationErrorKind, 26 RhiRuntimePrerequisite, RhiStateMetadata, RhiTimeEntropyAdapters, RhiTransportAdapters, 27 initialize_rhi_state, open_rhi_runtime_foundation, open_rhi_state_read_write, 28 parse_rhi_cli_v1_from, parse_rhi_config_v1, provision_rhi_encrypted_identity, 29 resolve_rhi_runtime_context, resolve_rhi_wrapping_credential, 30 }; 31 use sha2::{Digest, Sha256}; 32 33 const CONFIG_EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); 34 const FOUNDATION_SOURCE: &str = include_str!("../src/runtime_foundation.rs"); 35 const CONTRACT_SOURCE: &str = 36 include_str!("../contracts/services_hardening/runtime_foundation.v1.json"); 37 38 fn digest(label: &str) -> [u8; 32] { 39 Sha256::digest(label.as_bytes()).into() 40 } 41 42 fn identity_secret() -> [u8; 32] { 43 let mut candidate = digest("radroots.rhi.runtime-foundation.identity.v1"); 44 while SecretKey::from_slice(&candidate).is_err() { 45 candidate = Sha256::digest(candidate).into(); 46 } 47 candidate 48 } 49 50 fn runtime(root: &Path) -> rhi::RhiRuntimeContext { 51 let invocation = parse_rhi_cli_v1_from([ 52 "rhi", 53 "--profile", 54 "repo-local", 55 "--instance", 56 "primary", 57 "--repo-local-root", 58 root.to_str().expect("UTF-8 root"), 59 "run", 60 ]) 61 .expect("invocation"); 62 resolve_rhi_runtime_context( 63 &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), 64 &invocation, 65 ) 66 .expect("runtime") 67 } 68 69 fn configuration(runtime: &rhi::RhiRuntimeContext, expected_identity: &str) -> RhiConfigDocumentV1 { 70 let source = CONFIG_EXAMPLE 71 .replace( 72 "/var/lib/radroots/services/rhi/default/secrets/service.identity.ncrypt", 73 runtime.identity_path().to_str().expect("identity path"), 74 ) 75 .replace(&"2".repeat(64), expected_identity); 76 parse_rhi_config_v1(source.as_bytes(), RhiConfigProfile::RepoLocal).expect("configuration") 77 } 78 79 fn evidence() -> (MigrationAppliedAtUnixSeconds, MigrationBuildIdentity) { 80 let applied_at = MigrationAppliedAtUnixSeconds::new(1_725_000_000).expect("time"); 81 let build = MigrationBuildIdentity::new( 82 env!("CARGO_PKG_VERSION"), 83 "1111111111111111111111111111111111111111", 84 "053d0c750bf9cd683c6ea37cefe7e79617ba629f", 85 "rustc-test", 86 "test-target", 87 "service-host", 88 1, 89 rhi::RHI_STATE_SCHEMA_VERSION, 90 1, 91 1, 92 1, 93 ) 94 .expect("build"); 95 (applied_at, build) 96 } 97 98 fn prepare( 99 runtime: &rhi::RhiRuntimeContext, 100 configuration: &RhiConfigDocumentV1, 101 ) -> RhiStateMetadata { 102 for directory in [ 103 runtime.context().paths().state(), 104 runtime.context().paths().secrets(), 105 ] { 106 fs::create_dir_all(directory).expect("directory"); 107 fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).expect("mode"); 108 } 109 let metadata = RhiStateMetadata::new( 110 runtime, 111 configuration, 112 SourceGeneration::new([0x6b; 32]).expect("generation"), 113 1_725_000_000_000, 114 ) 115 .expect("metadata"); 116 let binding = RhiIdentityEnvelopeBinding::from_configuration(configuration, &metadata) 117 .expect("identity binding"); 118 let credential_bytes = digest("radroots.rhi.runtime-foundation.credential.v1"); 119 let credential_path = runtime 120 .context() 121 .paths() 122 .secrets() 123 .join("service_wrapping_key"); 124 fs::write(&credential_path, credential_bytes).expect("credential"); 125 fs::set_permissions(&credential_path, fs::Permissions::from_mode(0o600)) 126 .expect("credential mode"); 127 let credential = resolve_rhi_wrapping_credential(runtime, &binding).expect("credential open"); 128 provision_rhi_encrypted_identity( 129 &binding, 130 &credential, 131 RhiEncryptedIdentityProvisioningMaterial::new( 132 identity_secret(), 133 digest("radroots.rhi.runtime-foundation.data-key.v1"), 134 [7; 24], 135 [9; 24], 136 ) 137 .expect("material"), 138 ) 139 .expect("identity provision"); 140 metadata 141 } 142 143 #[derive(Clone)] 144 struct TransportSpy(Arc<AtomicUsize>); 145 146 impl TransportSpy { 147 fn invoked(&self) -> usize { 148 self.0.load(Ordering::SeqCst) 149 } 150 151 fn mark(&self) { 152 self.0.fetch_add(1, Ordering::SeqCst); 153 } 154 } 155 156 impl EventSource for TransportSpy { 157 fn status(&self) -> BoxFuture<'_, Result<SourceStatus, TransportError>> { 158 self.mark(); 159 Box::pin(async { panic!("foundation must not observe source status") }) 160 } 161 162 fn fetch(&self, _request: FetchRequest) -> BoxFuture<'_, Result<FetchPage, TransportError>> { 163 self.mark(); 164 Box::pin(async { panic!("foundation must not fetch") }) 165 } 166 } 167 168 impl EventSubscriber for TransportSpy { 169 fn subscribe( 170 &self, 171 _request: SubscriptionRequest, 172 ) -> BoxFuture<'_, Result<BoxSubscription, TransportError>> { 173 self.mark(); 174 Box::pin(async { panic!("foundation must not subscribe") }) 175 } 176 } 177 178 impl EventSink for TransportSpy { 179 fn status(&self) -> BoxFuture<'_, Result<SinkStatus, TransportError>> { 180 self.mark(); 181 Box::pin(async { panic!("foundation must not observe sink status") }) 182 } 183 184 fn deliver( 185 &self, 186 _request: DeliveryRequest, 187 ) -> BoxFuture<'_, Result<DeliveryReceipt, SinkFailure>> { 188 self.mark(); 189 Box::pin(async { panic!("foundation must not publish") }) 190 } 191 } 192 193 fn adapters(spy: &TransportSpy) -> RhiRuntimeAdapters { 194 RhiRuntimeAdapters::new( 195 RhiTimeEntropyAdapters::system(), 196 RhiTransportAdapters::new( 197 Arc::new(spy.clone()), 198 Arc::new(spy.clone()), 199 Arc::new(spy.clone()), 200 ), 201 RhiIdentityCredentialAdapters::canonical(), 202 ) 203 } 204 205 #[tokio::test] 206 async fn foundation_proves_state_config_identity_and_contacts_no_transport() { 207 let directory = tempfile::tempdir().expect("root"); 208 let runtime = runtime(directory.path()); 209 let secret = identity_secret(); 210 let identity = Keys::new(SecretKey::from_slice(&secret).expect("secret")) 211 .public_key() 212 .to_hex(); 213 let configuration = configuration(&runtime, &identity); 214 let metadata = prepare(&runtime, &configuration); 215 let (applied_at, build) = evidence(); 216 initialize_rhi_state(&runtime, &metadata, applied_at, &build) 217 .await 218 .expect("initialize"); 219 let spy = TransportSpy(Arc::new(AtomicUsize::new(0))); 220 let foundation = open_rhi_runtime_foundation( 221 runtime.clone(), 222 configuration, 223 adapters(&spy), 224 applied_at, 225 &build, 226 ) 227 .await 228 .expect("foundation"); 229 230 assert_eq!(spy.invoked(), 0); 231 assert_eq!( 232 foundation.metadata().database().source_generation(), 233 metadata.database().source_generation() 234 ); 235 assert!(!foundation.readiness().is_ready()); 236 assert_eq!( 237 foundation.readiness().satisfied(), 238 [ 239 RhiRuntimePrerequisite::ExistingState, 240 RhiRuntimePrerequisite::DurableConfiguration, 241 RhiRuntimePrerequisite::VerifiedIdentity, 242 ] 243 ); 244 assert!( 245 foundation 246 .readiness() 247 .required() 248 .contains(&RhiRuntimePrerequisite::PresenceDesiredState) 249 ); 250 assert!(!foundation.readiness().reasons().is_empty()); 251 let rendered = format!("{foundation:?}"); 252 assert!(!rendered.contains(directory.path().to_string_lossy().as_ref())); 253 assert!(!rendered.contains(&identity)); 254 255 let contended = open_rhi_state_read_write(&runtime, &metadata, applied_at, &build) 256 .await 257 .expect_err("foundation retains writer authority"); 258 assert_eq!(contended.kind(), rhi::RhiStateHostErrorKind::ReadWriteOpen); 259 foundation.shutdown().await.expect("shutdown"); 260 let reopened = open_rhi_state_read_write(&runtime, &metadata, applied_at, &build) 261 .await 262 .expect("reopen"); 263 reopened.close().await.expect("close"); 264 } 265 266 #[tokio::test] 267 async fn missing_state_fails_before_identity_or_transport_access() { 268 let directory = tempfile::tempdir().expect("root"); 269 let runtime = runtime(directory.path()); 270 let secret = identity_secret(); 271 let identity = Keys::new(SecretKey::from_slice(&secret).expect("secret")) 272 .public_key() 273 .to_hex(); 274 let configuration = configuration(&runtime, &identity); 275 fs::create_dir_all(runtime.context().paths().state()).expect("state directory"); 276 fs::set_permissions( 277 runtime.context().paths().state(), 278 fs::Permissions::from_mode(0o700), 279 ) 280 .expect("state mode"); 281 let spy = TransportSpy(Arc::new(AtomicUsize::new(0))); 282 let (applied_at, build) = evidence(); 283 let error = open_rhi_runtime_foundation( 284 runtime.clone(), 285 configuration, 286 adapters(&spy), 287 applied_at, 288 &build, 289 ) 290 .await 291 .expect_err("missing state"); 292 assert_eq!(error.kind(), RhiRuntimeFoundationErrorKind::StateOpen); 293 assert_eq!(spy.invoked(), 0); 294 assert!(!runtime.artifacts().state_database().exists()); 295 } 296 297 #[tokio::test] 298 async fn mismatched_configuration_fails_before_identity_or_transport_access() { 299 let directory = tempfile::tempdir().expect("root"); 300 let runtime = runtime(directory.path()); 301 let secret = identity_secret(); 302 let identity = Keys::new(SecretKey::from_slice(&secret).expect("secret")) 303 .public_key() 304 .to_hex(); 305 let current = configuration(&runtime, &identity); 306 let metadata = prepare(&runtime, ¤t); 307 let (applied_at, build) = evidence(); 308 initialize_rhi_state(&runtime, &metadata, applied_at, &build) 309 .await 310 .expect("initialize"); 311 312 let changed_source = CONFIG_EXAMPLE 313 .replace( 314 "/var/lib/radroots/services/rhi/default/secrets/service.identity.ncrypt", 315 runtime.identity_path().to_str().expect("identity path"), 316 ) 317 .replace(&"2".repeat(64), &identity) 318 .replacen("level = \"info\"", "level = \"debug\"", 1); 319 let changed = parse_rhi_config_v1(changed_source.as_bytes(), RhiConfigProfile::RepoLocal) 320 .expect("changed configuration"); 321 let spy = TransportSpy(Arc::new(AtomicUsize::new(0))); 322 let error = 323 open_rhi_runtime_foundation(runtime.clone(), changed, adapters(&spy), applied_at, &build) 324 .await 325 .expect_err("configuration mismatch"); 326 assert_eq!(error.kind(), RhiRuntimeFoundationErrorKind::StateOpen); 327 assert_eq!(spy.invoked(), 0); 328 329 let reopened = open_rhi_state_read_write(&runtime, &metadata, applied_at, &build) 330 .await 331 .expect("failed foundation releases state authority"); 332 reopened.close().await.expect("close"); 333 } 334 335 #[tokio::test] 336 async fn identity_failure_after_state_open_releases_authority_without_transport_access() { 337 let directory = tempfile::tempdir().expect("root"); 338 let runtime = runtime(directory.path()); 339 let secret = identity_secret(); 340 let identity = Keys::new(SecretKey::from_slice(&secret).expect("secret")) 341 .public_key() 342 .to_hex(); 343 let configuration = configuration(&runtime, &identity); 344 let metadata = prepare(&runtime, &configuration); 345 let (applied_at, build) = evidence(); 346 initialize_rhi_state(&runtime, &metadata, applied_at, &build) 347 .await 348 .expect("initialize"); 349 fs::remove_file( 350 runtime 351 .context() 352 .paths() 353 .secrets() 354 .join("service_wrapping_key"), 355 ) 356 .expect("remove credential"); 357 358 let spy = TransportSpy(Arc::new(AtomicUsize::new(0))); 359 let error = open_rhi_runtime_foundation( 360 runtime.clone(), 361 configuration, 362 adapters(&spy), 363 applied_at, 364 &build, 365 ) 366 .await 367 .expect_err("missing credential"); 368 assert_eq!(error.kind(), RhiRuntimeFoundationErrorKind::IdentityAccess); 369 assert_eq!(spy.invoked(), 0); 370 371 let reopened = open_rhi_state_read_write(&runtime, &metadata, applied_at, &build) 372 .await 373 .expect("identity failure releases state authority"); 374 reopened.close().await.expect("close"); 375 } 376 377 #[test] 378 fn source_and_contract_keep_final_runtime_authority_deferred() { 379 let contract: serde_json::Value = serde_json::from_str(CONTRACT_SOURCE).expect("contract"); 380 assert_eq!(contract["transport"]["invoked_during_foundation"], false); 381 assert_eq!(contract["state_open"]["initialize_if_missing"], false); 382 assert!(FOUNDATION_SOURCE.contains("open_rhi_state_read_write_from_config")); 383 for forbidden in [ 384 "tokio::runtime", 385 "ctrl_c", 386 "signal_hook", 387 "std::process::exit", 388 ".fetch(", 389 ".subscribe(", 390 ".deliver(", 391 "println!", 392 "tracing::", 393 ] { 394 assert!(!FOUNDATION_SOURCE.contains(forbidden), "found {forbidden}"); 395 } 396 }