rhi

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

services_hardening_wave_100_b.rs (9339B)


      1 #![forbid(unsafe_code)]
      2 #![cfg(any(target_os = "linux", target_os = "macos"))]
      3 
      4 use std::{fs, os::unix::fs::PermissionsExt, path::Path};
      5 
      6 use nostr::{Keys, SecretKey};
      7 use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity};
      8 use radroots_storage::event::SourceGeneration;
      9 use rhi::{
     10     RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, RhiConfigProfile,
     11     RhiEncryptedIdentityProvisioningMaterial, RhiIdentityEnvelopeBinding, RhiStateMetadata,
     12     initialize_rhi_state, open_rhi_encrypted_identity, open_rhi_state_read_write,
     13     parse_rhi_cli_v1_from, parse_rhi_config_v1, provision_rhi_encrypted_identity,
     14     resolve_rhi_runtime_context, resolve_rhi_wrapping_credential,
     15 };
     16 use sha2::{Digest, Sha256};
     17 
     18 const CONFIG_EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml");
     19 const ENVELOPE_CONTRACT: &str =
     20     include_str!("../contracts/services_hardening/encrypted_identity_envelope.v1.json");
     21 const CREDENTIAL_CONTRACT: &str =
     22     include_str!("../contracts/services_hardening/wrapping_credential_resolution.v1.json");
     23 const CONFIG_SOURCE: &str = include_str!("../src/config_v1.rs");
     24 const CREDENTIAL_SOURCE: &str = include_str!("../src/identity_credential.rs");
     25 const ENVELOPE_SOURCE: &str = include_str!("../src/identity_envelope.rs");
     26 const STATE_HOST_SOURCE: &str = include_str!("../src/state_host.rs");
     27 const STATE_MAINTENANCE_SOURCE: &str = include_str!("../src/state_maintenance.rs");
     28 
     29 fn digest(label: &str) -> [u8; 32] {
     30     Sha256::digest(label.as_bytes()).into()
     31 }
     32 
     33 fn identity_secret() -> [u8; 32] {
     34     let mut candidate = digest("radroots.rhi.wave-100-b.identity-secret.v1");
     35     while SecretKey::from_slice(&candidate).is_err() {
     36         candidate = Sha256::digest(candidate).into();
     37     }
     38     candidate
     39 }
     40 
     41 fn runtime(root: &Path, instance: &str) -> rhi::RhiRuntimeContext {
     42     let root = root.to_str().expect("UTF-8 temporary root");
     43     let invocation = parse_rhi_cli_v1_from([
     44         "rhi",
     45         "--profile",
     46         "repo-local",
     47         "--instance",
     48         instance,
     49         "--repo-local-root",
     50         root,
     51         "run",
     52     ])
     53     .expect("valid repo-local invocation");
     54     resolve_rhi_runtime_context(
     55         &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()),
     56         &invocation,
     57     )
     58     .expect("runtime context")
     59 }
     60 
     61 fn configuration(
     62     runtime: &rhi::RhiRuntimeContext,
     63     expected_identity: &str,
     64 ) -> rhi::RhiConfigDocumentV1 {
     65     let source = CONFIG_EXAMPLE
     66         .replace(
     67             "/var/lib/radroots/services/rhi/default/secrets/service.identity.ncrypt",
     68             runtime
     69                 .identity_path()
     70                 .to_str()
     71                 .expect("UTF-8 identity artifact path"),
     72         )
     73         .replace(&"2".repeat(64), expected_identity);
     74     parse_rhi_config_v1(source.as_bytes(), RhiConfigProfile::RepoLocal)
     75         .expect("wave-two configuration")
     76 }
     77 
     78 fn prepare_secure_directory(path: &Path) {
     79     fs::create_dir_all(path).expect("secure directory");
     80     fs::set_permissions(path, fs::Permissions::from_mode(0o700)).expect("secure mode");
     81 }
     82 
     83 fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
     84     !needle.is_empty()
     85         && haystack
     86             .windows(needle.len())
     87             .any(|window| window == needle)
     88 }
     89 
     90 fn migration_evidence() -> (MigrationAppliedAtUnixSeconds, MigrationBuildIdentity) {
     91     let applied_at = MigrationAppliedAtUnixSeconds::new(1_725_000_000).expect("migration time");
     92     let build = MigrationBuildIdentity::new(
     93         env!("CARGO_PKG_VERSION"),
     94         "1111111111111111111111111111111111111111",
     95         "053d0c750bf9cd683c6ea37cefe7e79617ba629f",
     96         "rustc-test",
     97         "test-target",
     98         "service-host",
     99         1,
    100         rhi::RHI_STATE_SCHEMA_VERSION,
    101         1,
    102         1,
    103         1,
    104     )
    105     .expect("build identity");
    106     (applied_at, build)
    107 }
    108 
    109 #[tokio::test]
    110 async fn wave_two_composes_one_runtime_without_crossing_secret_or_state_authority() {
    111     let directory = tempfile::tempdir().expect("temporary root");
    112     let runtime = runtime(directory.path(), "primary");
    113     let secret = identity_secret();
    114     let expected_identity = Keys::new(SecretKey::from_slice(&secret).expect("identity secret"))
    115         .public_key()
    116         .to_hex();
    117     let configuration = configuration(&runtime, &expected_identity);
    118     let metadata = RhiStateMetadata::new(
    119         &runtime,
    120         &configuration,
    121         SourceGeneration::new([0x6b; 32]).expect("source generation"),
    122         1_725_000_000_000,
    123     )
    124     .expect("state metadata");
    125     let binding = RhiIdentityEnvelopeBinding::from_configuration(&configuration, &metadata)
    126         .expect("identity binding");
    127 
    128     prepare_secure_directory(runtime.context().paths().secrets());
    129     let credential_bytes = digest("radroots.rhi.wave-100-b.wrapping-credential.v1");
    130     let credential_path = runtime
    131         .context()
    132         .paths()
    133         .secrets()
    134         .join("service_wrapping_key");
    135     fs::write(&credential_path, credential_bytes).expect("credential artifact");
    136     fs::set_permissions(&credential_path, fs::Permissions::from_mode(0o600))
    137         .expect("credential mode");
    138     let credential =
    139         resolve_rhi_wrapping_credential(&runtime, &binding).expect("credential resolution");
    140 
    141     let material = RhiEncryptedIdentityProvisioningMaterial::new(
    142         secret,
    143         digest("radroots.rhi.wave-100-b.data-key.v1"),
    144         [7; 24],
    145         [9; 24],
    146     )
    147     .expect("provisioning material");
    148     let provisioned = provision_rhi_encrypted_identity(&binding, &credential, material)
    149         .expect("create-new identity provisioning");
    150     assert_eq!(provisioned.public_identity().as_hex(), expected_identity);
    151     let opened =
    152         open_rhi_encrypted_identity(&binding, &credential).expect("existing identity envelope");
    153     assert_eq!(opened.public_identity().as_hex(), expected_identity);
    154 
    155     prepare_secure_directory(runtime.context().paths().state());
    156     let (applied_at, build) = migration_evidence();
    157     initialize_rhi_state(&runtime, &metadata, applied_at, &build)
    158         .await
    159         .expect("create-new state initialization");
    160     let state = open_rhi_state_read_write(&runtime, &metadata, applied_at, &build)
    161         .await
    162         .expect("existing state open");
    163     state.close().await.expect("explicit state close");
    164 
    165     let state_bytes = fs::read(runtime.artifacts().state_database()).expect("state database");
    166     for forbidden in [
    167         secret.as_slice(),
    168         credential_bytes.as_slice(),
    169         b"RRS1".as_slice(),
    170         b"RHWK".as_slice(),
    171         b"service_wrapping_key".as_slice(),
    172     ] {
    173         assert!(
    174             !contains_bytes(&state_bytes, forbidden),
    175             "state database contains protected identity material"
    176         );
    177     }
    178     assert_eq!(
    179         runtime.identity_path().parent(),
    180         Some(runtime.context().paths().secrets())
    181     );
    182     assert_ne!(
    183         runtime.context().paths().state(),
    184         runtime.context().paths().secrets()
    185     );
    186     assert_eq!(
    187         fs::metadata(runtime.identity_path())
    188             .expect("identity metadata")
    189             .permissions()
    190             .mode()
    191             & 0o777,
    192         0o600
    193     );
    194     assert_eq!(
    195         fs::metadata(&credential_path)
    196             .expect("credential metadata")
    197             .permissions()
    198             .mode()
    199             & 0o777,
    200         0o600
    201     );
    202 }
    203 
    204 #[test]
    205 fn wave_two_contracts_freeze_backup_exclusion_at_the_sealed_maintenance_boundary() {
    206     let envelope: serde_json::Value =
    207         serde_json::from_str(ENVELOPE_CONTRACT).expect("envelope contract");
    208     let credential: serde_json::Value =
    209         serde_json::from_str(CREDENTIAL_CONTRACT).expect("credential contract");
    210     assert_eq!(envelope["backup"]["state_backup_includes_envelope"], false);
    211     assert_eq!(
    212         envelope["backup"]["state_backup_includes_wrapping_credential"],
    213         false
    214     );
    215     assert_eq!(
    216         envelope["backup"]["state_backup_includes_plaintext_identity"],
    217         false
    218     );
    219     assert_eq!(credential["backup_included"], false);
    220     assert!(STATE_HOST_SOURCE.contains("capture_online_backup"));
    221     assert!(!STATE_HOST_SOURCE.contains("verify_backup_bundle"));
    222     assert!(STATE_MAINTENANCE_SOURCE.contains("verify_backup_bundle"));
    223     assert!(!STATE_HOST_SOURCE.contains("finalize_staged_restore"));
    224 }
    225 
    226 #[test]
    227 fn wave_two_keeps_configuration_state_identity_and_credential_authorities_separate() {
    228     for forbidden in [
    229         "resolve_rhi_wrapping_credential",
    230         "RhiWrappingCredential",
    231         "provision_rhi_encrypted_identity",
    232         "open_rhi_encrypted_identity",
    233         "service_wrapping_key",
    234     ] {
    235         assert!(!STATE_HOST_SOURCE.contains(forbidden));
    236     }
    237     for forbidden in ["sqlx::", "std::fs::", "std::env::"] {
    238         assert!(
    239             !CONFIG_SOURCE.contains(forbidden),
    240             "configuration parser contains forbidden authority {forbidden}"
    241         );
    242     }
    243     for source in [CREDENTIAL_SOURCE, ENVELOPE_SOURCE] {
    244         for forbidden in ["sqlx::", "rusqlite::", "state.sqlite"] {
    245             assert!(!source.contains(forbidden));
    246         }
    247     }
    248     let root = Path::new(env!("CARGO_MANIFEST_DIR"));
    249     assert!(!root.join("src/host_identity.rs").exists());
    250     assert!(!root.join("src/identity_storage.rs").exists());
    251 }