myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

services_hardening_runtime_foundation.rs (18495B)


      1 #![forbid(unsafe_code)]
      2 #![cfg(any(target_os = "linux", target_os = "macos"))]
      3 
      4 use std::{error::Error, fs, os::unix::fs::PermissionsExt, path::Path};
      5 
      6 use myc::{
      7     MycConfigDocumentV1, MycConfigProfile, MycEncryptedIdentityProvisioningMaterial,
      8     MycProviderKind, MycProviderRole, MycRuntimeFoundationErrorKind, MycRuntimePrerequisite,
      9     MycStateMetadata, RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform,
     10     initialize_myc_state, open_myc_runtime_foundation, open_myc_state_read_write,
     11     parse_myc_cli_v1_from, parse_myc_config_v1, provision_myc_encrypted_identity,
     12     resolve_myc_runtime_context, resolve_myc_wrapping_credential,
     13 };
     14 use nostr::{Keys, SecretKey};
     15 use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity};
     16 use radroots_storage::event::SourceGeneration;
     17 use serde_json::json;
     18 
     19 const FOUNDATION_SOURCE: &str = include_str!("../src/runtime_foundation.rs");
     20 const LIB_SOURCE: &str = include_str!("../src/lib.rs");
     21 const CONTRACT_SOURCE: &str =
     22     include_str!("../contracts/services_hardening/runtime_foundation.v1.json");
     23 const CONFIG_EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml");
     24 
     25 const TRANSPORT_ENCRYPTED: &str = r#"[identity.transport]
     26 provider = "encrypted_file"
     27 envelope_path = "/var/lib/radroots/services/myc/primary/secrets/transport.identity.ncrypt"
     28 credential_reference = "transport_wrapping_key"
     29 expected_public_key = "4444444444444444444444444444444444444444444444444444444444444444""#;
     30 
     31 const DISCOVERY_ENCRYPTED: &str = r#"[identity.discovery.binding]
     32 provider = "encrypted_file"
     33 envelope_path = "/var/lib/radroots/services/myc/primary/secrets/discovery.identity.ncrypt"
     34 credential_reference = "discovery_wrapping_key"
     35 expected_public_key = "3333333333333333333333333333333333333333333333333333333333333333""#;
     36 
     37 fn runtime(root: &Path) -> myc::MycRuntimeContext {
     38     let invocation = parse_myc_cli_v1_from([
     39         "myc",
     40         "--profile",
     41         "repo-local",
     42         "--instance",
     43         "primary",
     44         "--repo-local-root",
     45         root.to_str().expect("UTF-8 temporary root"),
     46         "run",
     47     ])
     48     .expect("valid test invocation");
     49     resolve_myc_runtime_context(
     50         &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()),
     51         &invocation,
     52     )
     53     .expect("runtime context")
     54 }
     55 
     56 fn prepare_state_directory(runtime: &myc::MycRuntimeContext) {
     57     let directory = runtime.context().paths().state();
     58     fs::create_dir_all(directory).expect("state directory");
     59     fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).expect("state mode");
     60 }
     61 
     62 fn local_transport_block(root: &Path) -> String {
     63     format!(
     64         r#"[identity.transport]
     65 provider = "local_signer"
     66 socket_path = "{}"
     67 request_deadline_ms = 15000
     68 request_max_bytes = 65536
     69 response_max_bytes = 1048576
     70 concurrency = 32
     71 expected_public_key = "4444444444444444444444444444444444444444444444444444444444444444""#,
     72         root.join("transport-signer.sock").display()
     73     )
     74 }
     75 
     76 fn local_discovery_block(root: &Path) -> String {
     77     format!(
     78         r#"[identity.discovery.binding]
     79 provider = "local_signer"
     80 socket_path = "{}"
     81 request_deadline_ms = 15000
     82 request_max_bytes = 65536
     83 response_max_bytes = 1048576
     84 concurrency = 32
     85 expected_public_key = "3333333333333333333333333333333333333333333333333333333333333333""#,
     86         root.join("discovery-signer.sock").display()
     87     )
     88 }
     89 
     90 fn all_local_source(root: &Path) -> String {
     91     let transport = local_transport_block(root);
     92     let discovery = local_discovery_block(root);
     93     let source = CONFIG_EXAMPLE
     94         .replacen(TRANSPORT_ENCRYPTED, &transport, 1)
     95         .replacen(DISCOVERY_ENCRYPTED, &discovery, 1);
     96     assert!(!source.contains(TRANSPORT_ENCRYPTED));
     97     assert!(!source.contains(DISCOVERY_ENCRYPTED));
     98     source
     99 }
    100 
    101 fn all_local_configuration(root: &Path) -> MycConfigDocumentV1 {
    102     parse_myc_config_v1(
    103         all_local_source(root).as_bytes(),
    104         MycConfigProfile::RepoLocal,
    105     )
    106     .expect("all-local configuration")
    107 }
    108 
    109 fn example_configuration() -> MycConfigDocumentV1 {
    110     parse_myc_config_v1(CONFIG_EXAMPLE.as_bytes(), MycConfigProfile::RepoLocal)
    111         .expect("example configuration")
    112 }
    113 
    114 fn metadata(
    115     runtime: &myc::MycRuntimeContext,
    116     configuration: &MycConfigDocumentV1,
    117 ) -> MycStateMetadata {
    118     MycStateMetadata::new(
    119         runtime,
    120         configuration,
    121         SourceGeneration::new([0x5a; 32]).expect("generation"),
    122         1_725_000_000_000,
    123     )
    124     .expect("metadata")
    125 }
    126 
    127 fn migration_evidence() -> (MigrationAppliedAtUnixSeconds, MigrationBuildIdentity) {
    128     let applied_at = MigrationAppliedAtUnixSeconds::new(1_725_000_000).expect("migration time");
    129     let build = MigrationBuildIdentity::new(
    130         env!("CARGO_PKG_VERSION"),
    131         "1111111111111111111111111111111111111111",
    132         "053d0c750bf9cd683c6ea37cefe7e79617ba629f",
    133         "rustc-test",
    134         "test-target",
    135         "service-host",
    136         1,
    137         myc::MYC_STATE_SCHEMA_VERSION,
    138         1,
    139         1,
    140         1,
    141     )
    142     .expect("build identity");
    143     (applied_at, build)
    144 }
    145 
    146 #[tokio::test]
    147 async fn foundation_owns_existing_state_and_never_claims_unproven_readiness() {
    148     let directory = tempfile::tempdir().expect("temporary root");
    149     let runtime = runtime(directory.path());
    150     prepare_state_directory(&runtime);
    151     let configuration = all_local_configuration(directory.path());
    152     let state_metadata = metadata(&runtime, &configuration);
    153     let (applied_at, build) = migration_evidence();
    154     initialize_myc_state(&runtime, &state_metadata, applied_at, &build)
    155         .await
    156         .expect("state initialization");
    157 
    158     let foundation = open_myc_runtime_foundation(
    159         runtime.clone(),
    160         configuration,
    161         state_metadata.clone(),
    162         applied_at,
    163         &build,
    164     )
    165     .await
    166     .expect("existing-state foundation");
    167     for role in [
    168         MycProviderRole::Transport,
    169         MycProviderRole::User,
    170         MycProviderRole::Discovery,
    171     ] {
    172         assert_eq!(
    173             foundation.provider_kind(role),
    174             Some(MycProviderKind::LocalSigner)
    175         );
    176     }
    177     assert!(!foundation.readiness().is_ready());
    178     assert_eq!(
    179         foundation.readiness().required(),
    180         [
    181             MycRuntimePrerequisite::ExistingState,
    182             MycRuntimePrerequisite::TransportProvider,
    183             MycRuntimePrerequisite::UserProvider,
    184             MycRuntimePrerequisite::DiscoveryProvider,
    185             MycRuntimePrerequisite::OutboxRecovery,
    186             MycRuntimePrerequisite::RequiredRelayConnectivity,
    187             MycRuntimePrerequisite::RequiredRelaySubscription,
    188             MycRuntimePrerequisite::AdminListener,
    189         ]
    190     );
    191     assert_eq!(
    192         foundation.readiness().satisfied(),
    193         [MycRuntimePrerequisite::ExistingState]
    194     );
    195     assert_eq!(
    196         foundation
    197             .readiness()
    198             .reasons()
    199             .iter()
    200             .map(|reason| reason.as_str())
    201             .collect::<Vec<_>>(),
    202         [
    203             "admin_listener_failed",
    204             "outbox_invariant_failed",
    205             "required_relay_unavailable",
    206             "signer_provider_unavailable",
    207             "subscriber_not_active",
    208         ]
    209     );
    210 
    211     let rendered = format!("{foundation:?}");
    212     assert!(!rendered.contains(directory.path().to_string_lossy().as_ref()));
    213     assert!(!rendered.contains("transport-signer.sock"));
    214     assert!(!rendered.contains("4444444444444444"));
    215 
    216     let contended = open_myc_state_read_write(&runtime, &state_metadata, applied_at, &build)
    217         .await
    218         .expect_err("foundation retains writer authority");
    219     assert_eq!(contended.kind(), myc::MycStateHostErrorKind::ReadWriteOpen);
    220 
    221     foundation.shutdown().await.expect("joined shutdown");
    222     let reopened = open_myc_state_read_write(&runtime, &state_metadata, applied_at, &build)
    223         .await
    224         .expect("authority released after joined shutdown");
    225     reopened.close().await.expect("reopened state close");
    226 }
    227 
    228 #[tokio::test]
    229 async fn missing_state_and_configuration_mismatch_fail_without_mutation_or_lock_leak() {
    230     let missing_directory = tempfile::tempdir().expect("missing temporary root");
    231     let missing_runtime = runtime(missing_directory.path());
    232     prepare_state_directory(&missing_runtime);
    233     let missing_configuration = all_local_configuration(missing_directory.path());
    234     let missing_metadata = metadata(&missing_runtime, &missing_configuration);
    235     let (applied_at, build) = migration_evidence();
    236     let missing = open_myc_runtime_foundation(
    237         missing_runtime.clone(),
    238         missing_configuration,
    239         missing_metadata,
    240         applied_at,
    241         &build,
    242     )
    243     .await
    244     .expect_err("run never initializes missing state");
    245     assert_eq!(missing.kind(), MycRuntimeFoundationErrorKind::StateOpen);
    246     assert!(!missing_runtime.artifacts().state_database().exists());
    247 
    248     let directory = tempfile::tempdir().expect("mismatch temporary root");
    249     let runtime = runtime(directory.path());
    250     prepare_state_directory(&runtime);
    251     let original = all_local_configuration(directory.path());
    252     let state_metadata = metadata(&runtime, &original);
    253     initialize_myc_state(&runtime, &state_metadata, applied_at, &build)
    254         .await
    255         .expect("state initialization");
    256     let changed_source = CONFIG_EXAMPLE.replacen("level = \"info\"", "level = \"debug\"", 1);
    257     let changed = parse_myc_config_v1(changed_source.as_bytes(), MycConfigProfile::RepoLocal)
    258         .expect("changed configuration");
    259     let mismatch = open_myc_runtime_foundation(
    260         runtime.clone(),
    261         changed,
    262         state_metadata.clone(),
    263         applied_at,
    264         &build,
    265     )
    266     .await
    267     .expect_err("configuration digest mismatch");
    268     assert_eq!(
    269         mismatch.kind(),
    270         MycRuntimeFoundationErrorKind::InvalidBinding
    271     );
    272     assert!(Error::source(&mismatch).is_none());
    273 
    274     let reopened = open_myc_state_read_write(&runtime, &state_metadata, applied_at, &build)
    275         .await
    276         .expect("mismatch fails before writer acquisition");
    277     reopened.close().await.expect("reopened state close");
    278 }
    279 
    280 #[tokio::test]
    281 async fn joined_encrypted_provider_failure_closes_the_already_open_state() {
    282     let directory = tempfile::tempdir().expect("temporary root");
    283     let runtime = runtime(directory.path());
    284     prepare_state_directory(&runtime);
    285     let configuration = example_configuration();
    286     let state_metadata = metadata(&runtime, &configuration);
    287     let (applied_at, build) = migration_evidence();
    288     initialize_myc_state(&runtime, &state_metadata, applied_at, &build)
    289         .await
    290         .expect("state initialization");
    291 
    292     let failure = open_myc_runtime_foundation(
    293         runtime.clone(),
    294         configuration,
    295         state_metadata.clone(),
    296         applied_at,
    297         &build,
    298     )
    299     .await
    300     .expect_err("missing credential artifacts fail provider startup");
    301     assert_eq!(failure.kind(), MycRuntimeFoundationErrorKind::Provider);
    302     assert!(Error::source(&failure).is_none());
    303 
    304     let reopened = open_myc_state_read_write(&runtime, &state_metadata, applied_at, &build)
    305         .await
    306         .expect("failed provider startup closes state");
    307     reopened.close().await.expect("reopened state close");
    308 }
    309 
    310 #[tokio::test]
    311 async fn joined_encrypted_provider_success_is_retained_as_proven_startup_evidence() {
    312     let directory = tempfile::tempdir().expect("temporary root");
    313     let runtime = runtime(directory.path());
    314     prepare_state_directory(&runtime);
    315 
    316     let identity_secret = [1_u8; 32];
    317     let public_key = Keys::new(SecretKey::from_slice(&identity_secret).expect("identity secret"))
    318         .public_key()
    319         .to_hex();
    320     let envelope_parent = directory.path().join("envelopes");
    321     fs::create_dir(&envelope_parent).expect("envelope parent");
    322     fs::set_permissions(&envelope_parent, fs::Permissions::from_mode(0o700))
    323         .expect("envelope parent mode");
    324     let envelope_path = envelope_parent.join("transport.identity.ncrypt");
    325     let transport_encrypted = format!(
    326         r#"[identity.transport]
    327 provider = "encrypted_file"
    328 envelope_path = "{}"
    329 credential_reference = "transport_wrapping_key"
    330 expected_public_key = "{}""#,
    331         envelope_path.display(),
    332         public_key
    333     );
    334     let source = all_local_source(directory.path()).replacen(
    335         &local_transport_block(directory.path()),
    336         &transport_encrypted,
    337         1,
    338     );
    339     let configuration = parse_myc_config_v1(source.as_bytes(), MycConfigProfile::RepoLocal)
    340         .expect("mixed provider configuration");
    341     let binding = configuration
    342         .provider_contract()
    343         .binding(MycProviderRole::Transport)
    344         .expect("transport binding");
    345 
    346     let secrets = runtime.context().paths().secrets();
    347     fs::create_dir_all(secrets).expect("secrets directory");
    348     fs::set_permissions(secrets, fs::Permissions::from_mode(0o700))
    349         .expect("secrets directory mode");
    350     let credential_path = secrets.join("transport_wrapping_key");
    351     fs::write(&credential_path, [9_u8; 32]).expect("offline credential fixture");
    352     fs::set_permissions(&credential_path, fs::Permissions::from_mode(0o600))
    353         .expect("credential mode");
    354     let credential =
    355         resolve_myc_wrapping_credential(&runtime, binding).expect("credential resolution");
    356     let material = MycEncryptedIdentityProvisioningMaterial::new(
    357         identity_secret,
    358         [2_u8; 32],
    359         [3_u8; 24],
    360         [4_u8; 24],
    361     )
    362     .expect("provisioning material");
    363     provision_myc_encrypted_identity(binding, &credential, material)
    364         .expect("offline envelope provisioning");
    365 
    366     let state_metadata = metadata(&runtime, &configuration);
    367     let (applied_at, build) = migration_evidence();
    368     initialize_myc_state(&runtime, &state_metadata, applied_at, &build)
    369         .await
    370         .expect("state initialization");
    371     let foundation =
    372         open_myc_runtime_foundation(runtime, configuration, state_metadata, applied_at, &build)
    373             .await
    374             .expect("joined encrypted-provider startup");
    375     assert_eq!(
    376         foundation.provider_kind(MycProviderRole::Transport),
    377         Some(MycProviderKind::EncryptedFile)
    378     );
    379     assert!(
    380         foundation
    381             .readiness()
    382             .satisfied()
    383             .contains(&MycRuntimePrerequisite::TransportProvider)
    384     );
    385     assert!(!foundation.readiness().is_ready());
    386     foundation.shutdown().await.expect("joined shutdown");
    387 }
    388 
    389 #[test]
    390 fn machine_contract_and_source_keep_the_foundation_sealed_and_deferred() {
    391     let contract: serde_json::Value =
    392         serde_json::from_str(CONTRACT_SOURCE).expect("runtime-foundation contract");
    393     assert_eq!(
    394         contract,
    395         json!({
    396             "schema": "radroots.myc.runtime-foundation",
    397             "schema_version": 1,
    398             "contract_version": 1,
    399             "state_open": {
    400                 "mode": "read_write_existing",
    401                 "initialize_if_missing": false,
    402                 "raw_sqlite_authority_exposed": false
    403             },
    404             "provider_startup": {
    405                 "encrypted_file": "supervised_joined_one_shot",
    406                 "local_signer": "constructed_without_io_pending_handshake",
    407                 "database_transaction_held": false,
    408                 "detached_tasks": false,
    409                 "protected_values_exposed": false
    410             },
    411             "readiness_prerequisites": [
    412                 { "id": "existing_state", "condition": "always", "reason": "database_schema_mismatch" },
    413                 { "id": "transport_provider", "condition": "always", "reason": "signer_provider_unavailable" },
    414                 { "id": "user_provider", "condition": "always", "reason": "signer_provider_unavailable" },
    415                 { "id": "discovery_provider", "condition": "discovery_enabled", "reason": "signer_provider_unavailable" },
    416                 { "id": "outbox_recovery", "condition": "always", "reason": "outbox_invariant_failed" },
    417                 { "id": "required_relay_connectivity", "condition": "required_relay_present", "reason": "required_relay_unavailable" },
    418                 { "id": "required_relay_subscription", "condition": "required_read_relay_present", "reason": "subscriber_not_active" },
    419                 { "id": "admin_listener", "condition": "always", "reason": "admin_listener_failed" },
    420                 { "id": "operations_listener", "condition": "operations_enabled", "reason": "operations_listener_failed" }
    421             ],
    422             "initial_satisfaction": {
    423                 "existing_state": "after_exact_existing_open",
    424                 "encrypted_file_provider": "after_joined_identity_verification",
    425                 "local_signer_provider": "not_before_verified_describe_handshake",
    426                 "remaining_prerequisites": "later_owning_rcld"
    427             },
    428             "task_ownership": {
    429                 "shared_supervisor": "radroots_service_host::TaskSupervisor",
    430                 "startup_tasks": "one_shot",
    431                 "lifetime_task": "critical_until_cancellation",
    432                 "task_handles_exposed": false,
    433                 "library_runtime_creation": false,
    434                 "signal_installation": false,
    435                 "process_exit": false
    436             },
    437             "deferred": [
    438                 "provider_handshake",
    439                 "outbox_recovery",
    440                 "relay_connectivity",
    441                 "relay_subscription",
    442                 "admin_listener",
    443                 "operations_listener",
    444                 "cached_status",
    445                 "signals",
    446                 "final_supervised_task_graph"
    447             ]
    448         })
    449     );
    450 
    451     assert!(LIB_SOURCE.contains("mod runtime_foundation;"));
    452     assert!(!LIB_SOURCE.contains("pub mod runtime_foundation;"));
    453     for required in [
    454         "TaskSupervisor::new()",
    455         "TaskClassification::OneShot",
    456         "thread.join()",
    457         "open_myc_state_read_write",
    458     ] {
    459         assert!(
    460             FOUNDATION_SOURCE.contains(required),
    461             "missing required foundation boundary `{required}`"
    462         );
    463     }
    464     for forbidden in [
    465         "tokio::spawn",
    466         "spawn_blocking",
    467         "JoinHandle",
    468         "Runtime::new",
    469         "process::exit",
    470         "pub fn pool",
    471         "pub fn connection",
    472         "AdminServer::",
    473         "TcpListener",
    474     ] {
    475         assert!(
    476             !FOUNDATION_SOURCE.contains(forbidden),
    477             "found deferred or escaping authority `{forbidden}`"
    478         );
    479     }
    480 }