myc

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

services_hardening_state_repository.rs (12212B)


      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     MYC_STATE_SCHEMA_VERSION, MycConfigProfile, MycStateHostErrorKind, MycStateMetadata,
      8     MycStateRepositoryErrorKind, RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform,
      9     initialize_myc_state, open_myc_state_inspection, open_myc_state_read_write,
     10     parse_myc_cli_v1_from, parse_myc_config_v1, resolve_myc_runtime_context,
     11 };
     12 use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity};
     13 use radroots_storage::event::SourceGeneration;
     14 use sqlx::{ConnectOptions, Connection, Row, sqlite::SqliteConnectOptions};
     15 
     16 const CONFIG_EXAMPLE: &[u8] =
     17     include_bytes!("../contracts/services_hardening/config.v1.example.toml");
     18 const LIB_SOURCE: &str = include_str!("../src/lib.rs");
     19 const HOST_SOURCE: &str = include_str!("../src/state_host.rs");
     20 const REPOSITORY_SOURCE: &str = include_str!("../src/state_repository.rs");
     21 
     22 fn runtime(root: &Path) -> myc::MycRuntimeContext {
     23     let root = root.to_str().expect("UTF-8 temporary root");
     24     let invocation = parse_myc_cli_v1_from([
     25         "myc",
     26         "--profile",
     27         "repo-local",
     28         "--instance",
     29         "primary",
     30         "--repo-local-root",
     31         root,
     32         "run",
     33     ])
     34     .expect("valid test invocation");
     35     resolve_myc_runtime_context(
     36         &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()),
     37         &invocation,
     38     )
     39     .expect("runtime context")
     40 }
     41 
     42 fn prepare_state_directory(runtime: &myc::MycRuntimeContext) {
     43     let directory = runtime.context().paths().state();
     44     fs::create_dir_all(directory).expect("state directory");
     45     fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).expect("state mode");
     46 }
     47 
     48 fn metadata(runtime: &myc::MycRuntimeContext, source: &[u8]) -> MycStateMetadata {
     49     let configuration =
     50         parse_myc_config_v1(source, MycConfigProfile::RepoLocal).expect("configuration");
     51     MycStateMetadata::new(
     52         runtime,
     53         &configuration,
     54         SourceGeneration::new([0x5a; 32]).expect("generation"),
     55         1_725_000_000_000,
     56     )
     57     .expect("metadata")
     58 }
     59 
     60 fn migration_evidence() -> (MigrationAppliedAtUnixSeconds, MigrationBuildIdentity) {
     61     let applied_at = MigrationAppliedAtUnixSeconds::new(1_725_000_000).expect("migration time");
     62     let build = MigrationBuildIdentity::new(
     63         env!("CARGO_PKG_VERSION"),
     64         "1111111111111111111111111111111111111111",
     65         "053d0c750bf9cd683c6ea37cefe7e79617ba629f",
     66         "rustc-test",
     67         "test-target",
     68         "service-host",
     69         1,
     70         MYC_STATE_SCHEMA_VERSION,
     71         1,
     72         1,
     73         1,
     74     )
     75     .expect("build identity");
     76     (applied_at, build)
     77 }
     78 
     79 #[tokio::test]
     80 async fn initialization_migrates_and_binds_exact_metadata_before_inspection() {
     81     let directory = tempfile::tempdir().expect("temporary root");
     82     let runtime = runtime(directory.path());
     83     prepare_state_directory(&runtime);
     84     let metadata = metadata(&runtime, CONFIG_EXAMPLE);
     85     let (applied_at, build) = migration_evidence();
     86 
     87     initialize_myc_state(&runtime, &metadata, applied_at, &build)
     88         .await
     89         .expect("initialized current state");
     90     let inspection = open_myc_state_inspection(&runtime, &metadata)
     91         .await
     92         .expect("current inspection");
     93     inspection
     94         .repository()
     95         .verify_binding()
     96         .await
     97         .expect("typed repository verification");
     98     assert_eq!(
     99         format!("{:?}", inspection.repository()),
    100         "MycStateRepository { state: \"[sealed]\" }"
    101     );
    102     inspection.close().await.expect("inspection close");
    103 
    104     let options = SqliteConnectOptions::new()
    105         .filename(runtime.artifacts().state_database())
    106         .create_if_missing(false)
    107         .read_only(true)
    108         .disable_statement_logging();
    109     let mut connection = sqlx::SqliteConnection::connect_with(&options)
    110         .await
    111         .expect("test inspection connection");
    112     let row = sqlx::query(
    113         "SELECT state_schema_version FROM radroots_service_metadata WHERE singleton = 1",
    114     )
    115     .fetch_one(&mut connection)
    116     .await
    117     .expect("shared metadata row");
    118     assert_eq!(row.get::<i64, _>(0), i64::from(MYC_STATE_SCHEMA_VERSION));
    119     let migrations = sqlx::query("SELECT version, name FROM schema_migrations ORDER BY version")
    120         .fetch_all(&mut connection)
    121         .await
    122         .expect("migration rows");
    123     assert_eq!(migrations.len(), 11);
    124     assert_eq!(migrations[0].get::<i64, _>(0), 2);
    125     assert_eq!(
    126         migrations[0].get::<String, _>(1),
    127         "create_myc_state_metadata"
    128     );
    129     assert_eq!(migrations[1].get::<i64, _>(0), 3);
    130     assert_eq!(
    131         migrations[1].get::<String, _>(1),
    132         "create_nip46_request_admission"
    133     );
    134     assert_eq!(migrations[2].get::<i64, _>(0), 4);
    135     assert_eq!(
    136         migrations[2].get::<String, _>(1),
    137         "create_connection_authorization_state"
    138     );
    139     assert_eq!(migrations[3].get::<i64, _>(0), 5);
    140     assert_eq!(
    141         migrations[3].get::<String, _>(1),
    142         "create_bounded_governance_state"
    143     );
    144     assert_eq!(migrations[4].get::<i64, _>(0), 6);
    145     assert_eq!(
    146         migrations[4].get::<String, _>(1),
    147         "create_delivery_evidence_state"
    148     );
    149     assert_eq!(migrations[5].get::<i64, _>(0), 7);
    150     assert_eq!(
    151         migrations[5].get::<String, _>(1),
    152         "create_discovery_desired_state"
    153     );
    154     assert_eq!(migrations[6].get::<i64, _>(0), 8);
    155     assert_eq!(
    156         migrations[6].get::<String, _>(1),
    157         "create_nip46_operation_completion"
    158     );
    159     assert_eq!(migrations[7].get::<i64, _>(0), 9);
    160     assert_eq!(
    161         migrations[7].get::<String, _>(1),
    162         "create_nip46_atomic_response"
    163     );
    164     assert_eq!(migrations[8].get::<i64, _>(0), 10);
    165     assert_eq!(
    166         migrations[8].get::<String, _>(1),
    167         "create_configuration_binding_history"
    168     );
    169     assert_eq!(migrations[9].get::<i64, _>(0), 11);
    170     assert_eq!(
    171         migrations[9].get::<String, _>(1),
    172         "create_admin_operation_journal"
    173     );
    174     assert_eq!(migrations[10].get::<i64, _>(0), 12);
    175     assert_eq!(
    176         migrations[10].get::<String, _>(1),
    177         "create_nip46_pending_response_authority"
    178     );
    179     let binding = sqlx::query(
    180         "SELECT normalized_config_sha256, transport_public_key, user_public_key, \
    181          discovery_public_key, config_contract_version, state_contract_version, \
    182          operator_contract_version, status_contract_version \
    183          FROM myc_state_metadata WHERE singleton = 1",
    184     )
    185     .fetch_one(&mut connection)
    186     .await
    187     .expect("Myc metadata row");
    188     assert_eq!(
    189         binding.get::<Vec<u8>, _>(0),
    190         metadata.configuration_digest().as_bytes()
    191     );
    192     assert_eq!(
    193         binding.get::<String, _>(1),
    194         metadata.expected_identities().transport().as_hex()
    195     );
    196     assert_eq!(
    197         binding.get::<String, _>(2),
    198         metadata.expected_identities().user().as_hex()
    199     );
    200     assert_eq!(
    201         binding.get::<String, _>(3),
    202         metadata
    203             .expected_identities()
    204             .discovery()
    205             .expect("discovery identity")
    206             .as_hex()
    207     );
    208     assert_eq!(binding.get::<i64, _>(4), 1);
    209     assert_eq!(
    210         binding.get::<i64, _>(5),
    211         i64::from(MYC_STATE_SCHEMA_VERSION)
    212     );
    213     assert_eq!(binding.get::<i64, _>(6), 1);
    214     assert_eq!(binding.get::<i64, _>(7), 1);
    215     connection.close().await.expect("test connection close");
    216 }
    217 
    218 #[tokio::test]
    219 async fn exact_binding_is_idempotent_and_conflicting_configuration_fails_closed() {
    220     let directory = tempfile::tempdir().expect("temporary root");
    221     let runtime = runtime(directory.path());
    222     prepare_state_directory(&runtime);
    223     let expected = metadata(&runtime, CONFIG_EXAMPLE);
    224     let (applied_at, build) = migration_evidence();
    225     initialize_myc_state(&runtime, &expected, applied_at, &build)
    226         .await
    227         .expect("initialization");
    228 
    229     let writer = open_myc_state_read_write(&runtime, &expected, applied_at, &build)
    230         .await
    231         .expect("idempotent exact open");
    232     writer
    233         .repository()
    234         .verify_binding()
    235         .await
    236         .expect("exact binding");
    237     writer.close().await.expect("writer close");
    238 
    239     let changed_source = String::from_utf8(CONFIG_EXAMPLE.to_vec())
    240         .expect("UTF-8 config")
    241         .replace("level = \"info\"", "level = \"warn\"");
    242     let changed = metadata(&runtime, changed_source.as_bytes());
    243     let error = open_myc_state_read_write(&runtime, &changed, applied_at, &build)
    244         .await
    245         .expect_err("changed normalized binding");
    246     assert_eq!(error.kind(), MycStateHostErrorKind::Repository);
    247 
    248     let inspection = open_myc_state_inspection(&runtime, &expected)
    249         .await
    250         .expect("original binding remains authoritative");
    251     inspection.close().await.expect("inspection close");
    252 }
    253 
    254 #[tokio::test]
    255 async fn database_guards_reject_metadata_update_and_delete() {
    256     let directory = tempfile::tempdir().expect("temporary root");
    257     let runtime = runtime(directory.path());
    258     prepare_state_directory(&runtime);
    259     let metadata = metadata(&runtime, CONFIG_EXAMPLE);
    260     let (applied_at, build) = migration_evidence();
    261     initialize_myc_state(&runtime, &metadata, applied_at, &build)
    262         .await
    263         .expect("initialization");
    264 
    265     let options = SqliteConnectOptions::new()
    266         .filename(runtime.artifacts().state_database())
    267         .create_if_missing(false)
    268         .disable_statement_logging();
    269     let mut connection = sqlx::SqliteConnection::connect_with(&options)
    270         .await
    271         .expect("test connection");
    272     assert!(
    273         sqlx::query("UPDATE myc_state_metadata SET status_contract_version = 2")
    274             .execute(&mut connection)
    275             .await
    276             .is_err()
    277     );
    278     assert!(
    279         sqlx::query("DELETE FROM myc_state_metadata")
    280             .execute(&mut connection)
    281             .await
    282             .is_err()
    283     );
    284     connection.close().await.expect("test connection close");
    285 }
    286 
    287 #[tokio::test]
    288 async fn repository_boundary_is_sealed_typed_redacted_and_network_free() {
    289     assert!(LIB_SOURCE.contains("mod state_repository;"));
    290     assert!(!LIB_SOURCE.contains("pub mod state_repository;"));
    291     assert!(HOST_SOURCE.contains("pub const fn repository(&self) -> MycStateRepository<'_>"));
    292     assert!(REPOSITORY_SOURCE.contains(".transaction(move |transaction|"));
    293     assert!(REPOSITORY_SOURCE.contains("INSERT INTO myc_state_metadata"));
    294     for forbidden in [
    295         "SqliteConnection",
    296         "SqlitePool",
    297         "PoolConnection",
    298         "BEGIN ",
    299         "COMMIT",
    300         "ROLLBACK",
    301         "MycProvider",
    302         "provider_credential",
    303         "provider_envelope",
    304         "relay",
    305         "reqwest",
    306         "nostr::",
    307         "std::fs",
    308         "std::path",
    309         "std::env",
    310         "std::time",
    311     ] {
    312         assert!(
    313             !REPOSITORY_SOURCE.contains(forbidden),
    314             "found forbidden repository authority `{forbidden}`"
    315         );
    316     }
    317 
    318     let kind = myc::MycStateRepositoryErrorKind::Binding;
    319     assert_eq!(kind.code(), "state_repository_binding_invalid");
    320     let rendered = format!("{kind:?}");
    321     assert!(!rendered.contains("state.sqlite"));
    322 
    323     let directory = tempfile::tempdir().expect("temporary root");
    324     let context = runtime(directory.path());
    325     prepare_state_directory(&context);
    326     let metadata = metadata(&context, CONFIG_EXAMPLE);
    327     let (applied_at, build) = migration_evidence();
    328     initialize_myc_state(&context, &metadata, applied_at, &build)
    329         .await
    330         .expect("initialization");
    331     let inspection = open_myc_state_inspection(&context, &metadata)
    332         .await
    333         .expect("inspection");
    334     inspection.close().await.expect("inspection close");
    335     let error = inspection
    336         .repository()
    337         .verify_binding()
    338         .await
    339         .expect_err("closed host rejects repository admission");
    340     assert_eq!(error.kind(), MycStateRepositoryErrorKind::Transaction);
    341     assert!(Error::source(&error).is_none());
    342     assert!(!format!("{error} {error:?}").contains("secret"));
    343 }