rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

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, &current);
    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 }