myc

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

services_hardening_config_lifecycle.rs (36296B)


      1 #![forbid(unsafe_code)]
      2 #![cfg(any(target_os = "linux", target_os = "macos"))]
      3 
      4 use std::{error::Error, fs, num::NonZeroU32, os::unix::fs::PermissionsExt, path::Path};
      5 
      6 use myc::{
      7     MycConfigApplyErrorKind, MycConfigProfile, MycStateHostErrorKind, MycStateMetadata,
      8     RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, initialize_myc_state,
      9     open_myc_state_inspection, open_myc_state_read_write, parse_myc_cli_v1_from,
     10     parse_myc_config_v1, resolve_myc_runtime_context,
     11 };
     12 use radroots_service_sqlite::{
     13     MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, MigrationCatalog, OpenMode,
     14     SchemaCatalog, ServiceDatabaseIdentity, ServiceSqliteConnectionOptions, ServiceSqliteHost,
     15     ServiceSqliteInitializer, ServiceSqliteInitializerFuture, ServiceSqlitePaths,
     16     initialize_database,
     17 };
     18 use radroots_storage::event::SourceGeneration;
     19 use sqlx::{ConnectOptions, Connection, Row, sqlite::SqliteConnectOptions};
     20 
     21 const CONFIG: &str = include_str!("../contracts/services_hardening/config.v1.example.toml");
     22 const CONFIG_SOURCE: &str = include_str!("../src/state_config.rs");
     23 const LIB_SOURCE: &str = include_str!("../src/lib.rs");
     24 
     25 fn runtime(root: &Path) -> myc::MycRuntimeContext {
     26     let invocation = parse_myc_cli_v1_from([
     27         "myc",
     28         "--profile",
     29         "repo-local",
     30         "--instance",
     31         "primary",
     32         "--repo-local-root",
     33         root.to_str().expect("UTF-8 root"),
     34         "run",
     35     ])
     36     .expect("invocation");
     37     resolve_myc_runtime_context(
     38         &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()),
     39         &invocation,
     40     )
     41     .expect("runtime")
     42 }
     43 
     44 fn prepare(runtime: &myc::MycRuntimeContext) {
     45     fs::create_dir_all(runtime.context().paths().state()).expect("state directory");
     46     fs::set_permissions(
     47         runtime.context().paths().state(),
     48         fs::Permissions::from_mode(0o700),
     49     )
     50     .expect("state mode");
     51 }
     52 
     53 fn configuration(source: &str) -> myc::MycConfigDocumentV1 {
     54     parse_myc_config_v1(source.as_bytes(), MycConfigProfile::RepoLocal).expect("configuration")
     55 }
     56 
     57 fn metadata(
     58     runtime: &myc::MycRuntimeContext,
     59     configuration: &myc::MycConfigDocumentV1,
     60 ) -> MycStateMetadata {
     61     MycStateMetadata::new(
     62         runtime,
     63         configuration,
     64         SourceGeneration::new([0x5a; 32]).expect("generation"),
     65         1_725_000_000_000,
     66     )
     67     .expect("metadata")
     68 }
     69 
     70 fn build() -> MigrationBuildIdentity {
     71     build_for_schema(myc::MYC_STATE_SCHEMA_VERSION)
     72 }
     73 
     74 fn build_for_schema(state_schema_version: u32) -> MigrationBuildIdentity {
     75     build_for_contracts(state_schema_version, 1)
     76 }
     77 
     78 fn build_for_contracts(
     79     state_schema_version: u32,
     80     provider_contract_version: u32,
     81 ) -> MigrationBuildIdentity {
     82     MigrationBuildIdentity::new(
     83         env!("CARGO_PKG_VERSION"),
     84         "1111111111111111111111111111111111111111",
     85         "053d0c750bf9cd683c6ea37cefe7e79617ba629f",
     86         "rustc-test",
     87         "test-target",
     88         "service-host",
     89         1,
     90         state_schema_version,
     91         1,
     92         1,
     93         provider_contract_version,
     94     )
     95     .expect("build")
     96 }
     97 
     98 #[derive(Debug)]
     99 struct TestInitializationError;
    100 
    101 impl std::fmt::Display for TestInitializationError {
    102     fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    103         formatter.write_str("test catalog initialization failed")
    104     }
    105 }
    106 
    107 impl Error for TestInitializationError {}
    108 
    109 fn initialize_empty_catalog<'a>(
    110     _initializer: &'a mut ServiceSqliteInitializer<'_>,
    111 ) -> ServiceSqliteInitializerFuture<'a, TestInitializationError> {
    112     Box::pin(async { Ok(()) })
    113 }
    114 
    115 async fn initialize_v9(runtime: &myc::MycRuntimeContext, metadata: &MycStateMetadata) {
    116     let full_migrations = myc::myc_migration_catalog().expect("full migrations");
    117     let migrations = MigrationCatalog::new(full_migrations.descriptors()[..8].iter().cloned())
    118         .expect("v9 migrations");
    119     let full_schema = myc::myc_schema_catalog().expect("full schema");
    120     let schema = SchemaCatalog::new(&migrations, full_schema.versions()[..9].iter().copied())
    121         .expect("v9 schema");
    122     let paths = ServiceSqlitePaths::from_runtime_context(runtime.context()).expect("paths");
    123     let initial = metadata.initial_database_metadata();
    124     let identity = ServiceDatabaseIdentity::new(
    125         &paths,
    126         initial.source_generation(),
    127         NonZeroU32::new(9).unwrap(),
    128         initial.application_id(),
    129     );
    130     let authority = initialize_database(
    131         &paths,
    132         OpenMode::Initialize,
    133         initial,
    134         &schema,
    135         initialize_empty_catalog,
    136     )
    137     .await
    138     .expect("v9 initialize");
    139     let (host, outcome) = ServiceSqliteHost::open_initialized(
    140         &paths,
    141         &identity,
    142         &migrations,
    143         &schema,
    144         ServiceSqliteConnectionOptions::reviewed(),
    145         authority,
    146         MigrationAppliedAtUnixSeconds::new(1_725_000_000).unwrap(),
    147         &build_for_schema(9),
    148         &[],
    149     )
    150     .await
    151     .expect("v9 migrations");
    152     assert_eq!(outcome.final_version(), 9);
    153     assert_eq!(outcome.applied_count(), 8);
    154 
    155     let digest = *metadata.configuration_digest().as_bytes();
    156     let identities = metadata.expected_identities();
    157     let transport: Box<str> = identities.transport().as_hex().into();
    158     let user: Box<str> = identities.user().as_hex().into();
    159     let discovery: Option<Box<str>> = identities.discovery().map(|value| value.as_hex().into());
    160     let versions = metadata.policy_versions();
    161     host.transaction(move |transaction| {
    162         Box::pin(async move {
    163             sqlx::query(
    164                 "INSERT INTO myc_state_metadata (singleton, normalized_config_sha256, \
    165                  transport_public_key, user_public_key, discovery_public_key, \
    166                  config_contract_version, state_contract_version, operator_contract_version, \
    167                  status_contract_version) VALUES (1, ?, ?, ?, ?, ?, 9, ?, ?)",
    168             )
    169             .bind(digest.as_slice())
    170             .bind(transport.as_ref())
    171             .bind(user.as_ref())
    172             .bind(discovery.as_deref())
    173             .bind(i64::from(versions.configuration()))
    174             .bind(i64::from(versions.operator()))
    175             .bind(i64::from(versions.status()))
    176             .execute(&mut *transaction)
    177             .await
    178             .map(|_| ())
    179         })
    180     })
    181     .await
    182     .expect("v9 Myc birth binding");
    183     host.close().await.expect("v9 close");
    184 }
    185 
    186 async fn initialize_v10(runtime: &myc::MycRuntimeContext, metadata: &MycStateMetadata) {
    187     let full_migrations = myc::myc_migration_catalog().expect("full migrations");
    188     let migrations = MigrationCatalog::new(full_migrations.descriptors()[..9].iter().cloned())
    189         .expect("v10 migrations");
    190     let full_schema = myc::myc_schema_catalog().expect("full schema");
    191     let schema = SchemaCatalog::new(&migrations, full_schema.versions()[..10].iter().copied())
    192         .expect("v10 schema");
    193     let paths = ServiceSqlitePaths::from_runtime_context(runtime.context()).expect("paths");
    194     let initial = metadata.initial_database_metadata();
    195     let identity = ServiceDatabaseIdentity::new(
    196         &paths,
    197         initial.source_generation(),
    198         NonZeroU32::new(10).unwrap(),
    199         initial.application_id(),
    200     );
    201     let authority = initialize_database(
    202         &paths,
    203         OpenMode::Initialize,
    204         initial,
    205         &schema,
    206         initialize_empty_catalog,
    207     )
    208     .await
    209     .expect("v10 initialize");
    210     let (host, outcome) = ServiceSqliteHost::open_initialized(
    211         &paths,
    212         &identity,
    213         &migrations,
    214         &schema,
    215         ServiceSqliteConnectionOptions::reviewed(),
    216         authority,
    217         MigrationAppliedAtUnixSeconds::new(1_725_000_000).unwrap(),
    218         &build_for_schema(10),
    219         &[],
    220     )
    221     .await
    222     .expect("v10 migrations");
    223     assert_eq!(outcome.final_version(), 10);
    224     assert_eq!(outcome.applied_count(), 9);
    225 
    226     let digest = *metadata.configuration_digest().as_bytes();
    227     let identities = metadata.expected_identities();
    228     let transport: Box<str> = identities.transport().as_hex().into();
    229     let user: Box<str> = identities.user().as_hex().into();
    230     let discovery: Option<Box<str>> = identities.discovery().map(|value| value.as_hex().into());
    231     let versions = metadata.policy_versions();
    232     host.transaction(move |transaction| {
    233         Box::pin(async move {
    234             sqlx::query(
    235                 "INSERT INTO myc_state_metadata (singleton, normalized_config_sha256, \
    236                  transport_public_key, user_public_key, discovery_public_key, \
    237                  config_contract_version, state_contract_version, operator_contract_version, \
    238                  status_contract_version) VALUES (1, ?, ?, ?, ?, ?, 10, ?, ?)",
    239             )
    240             .bind(digest.as_slice())
    241             .bind(transport.as_ref())
    242             .bind(user.as_ref())
    243             .bind(discovery.as_deref())
    244             .bind(i64::from(versions.configuration()))
    245             .bind(i64::from(versions.operator()))
    246             .bind(i64::from(versions.status()))
    247             .execute(&mut *transaction)
    248             .await?;
    249             sqlx::query(
    250                 "INSERT INTO myc_config_bindings (generation, normalized_config_sha256, \
    251                  transport_public_key, user_public_key, discovery_public_key, \
    252                  config_contract_version, state_contract_version, operator_contract_version, \
    253                  status_contract_version, applied_at_unix_s, service_version, service_commit, \
    254                  lib_revision, rust_version, target, feature_profile, provider_contract_version) \
    255                  SELECT 1, normalized_config_sha256, transport_public_key, user_public_key, \
    256                  discovery_public_key, config_contract_version, 10, operator_contract_version, \
    257                  status_contract_version, 1725000000, '0.1.0', \
    258                  '1111111111111111111111111111111111111111', \
    259                  '053d0c750bf9cd683c6ea37cefe7e79617ba629f', 'rustc-test', 'test-target', \
    260                  'service-host', 1 FROM myc_state_metadata WHERE singleton = 1",
    261             )
    262             .execute(&mut *transaction)
    263             .await
    264             .map(|_| ())
    265         })
    266     })
    267     .await
    268     .expect("v10 Myc binding");
    269     host.close().await.expect("v10 close");
    270 }
    271 
    272 async fn initialize(runtime: &myc::MycRuntimeContext, metadata: &MycStateMetadata) {
    273     initialize_myc_state(
    274         runtime,
    275         metadata,
    276         MigrationAppliedAtUnixSeconds::new(1_725_000_000).unwrap(),
    277         &build(),
    278     )
    279     .await
    280     .expect("initialize");
    281 }
    282 
    283 fn options(runtime: &myc::MycRuntimeContext) -> SqliteConnectOptions {
    284     SqliteConnectOptions::new()
    285         .filename(runtime.artifacts().state_database())
    286         .create_if_missing(false)
    287         .disable_statement_logging()
    288 }
    289 
    290 #[tokio::test]
    291 async fn offline_apply_appends_one_generation_and_rebinds_future_startup() {
    292     let directory = tempfile::tempdir().expect("root");
    293     let runtime = runtime(directory.path());
    294     prepare(&runtime);
    295     let current = configuration(CONFIG);
    296     let current_metadata = metadata(&runtime, &current);
    297     initialize(&runtime, &current_metadata).await;
    298 
    299     let candidate_source = CONFIG.replace("level = \"info\"", "level = \"warn\"");
    300     let candidate = configuration(&candidate_source);
    301     let writer = open_myc_state_read_write(
    302         &runtime,
    303         &current_metadata,
    304         MigrationAppliedAtUnixSeconds::new(1_725_000_001).unwrap(),
    305         &build(),
    306     )
    307     .await
    308     .expect("writer");
    309     let second_writer = open_myc_state_read_write(
    310         &runtime,
    311         &current_metadata,
    312         MigrationAppliedAtUnixSeconds::new(1_725_000_001).unwrap(),
    313         &build(),
    314     )
    315     .await
    316     .expect_err("exclusive writer authority must reject a concurrent daemon");
    317     assert_eq!(second_writer.kind(), MycStateHostErrorKind::ReadWriteOpen);
    318 
    319     let mismatched_build = build_for_contracts(myc::MYC_STATE_SCHEMA_VERSION, 2);
    320     let mismatch = writer
    321         .repository()
    322         .apply_configuration(
    323             &current,
    324             &candidate,
    325             MigrationAppliedAtUnixSeconds::new(1_725_000_002).unwrap(),
    326             &mismatched_build,
    327         )
    328         .await
    329         .expect_err("provider contract mismatch");
    330     assert_eq!(mismatch.kind(), MycConfigApplyErrorKind::InvalidInput);
    331 
    332     let outcome = writer
    333         .repository()
    334         .apply_configuration(
    335             &current,
    336             &candidate,
    337             MigrationAppliedAtUnixSeconds::new(1_725_000_002).unwrap(),
    338             &build(),
    339         )
    340         .await
    341         .expect("apply");
    342     assert_eq!(outcome.generation(), 2);
    343     assert_eq!(outcome.revoked_connection_count(), 0);
    344     assert_eq!(outcome.revoked_challenge_count(), 0);
    345     assert_eq!(
    346         format!("{outcome:?}"),
    347         "MycConfigApplyOutcome { generation: 2, revoked_connections: 0, revoked_challenges: 0 }"
    348     );
    349     writer.close().await.expect("close");
    350 
    351     let old = open_myc_state_read_write(
    352         &runtime,
    353         &current_metadata,
    354         MigrationAppliedAtUnixSeconds::new(1_725_000_003).unwrap(),
    355         &build(),
    356     )
    357     .await
    358     .expect_err("old configuration must no longer bind");
    359     assert_eq!(old.kind(), MycStateHostErrorKind::Repository);
    360 
    361     let candidate_metadata = metadata(&runtime, &candidate);
    362     let writer = open_myc_state_read_write(
    363         &runtime,
    364         &candidate_metadata,
    365         MigrationAppliedAtUnixSeconds::new(1_725_000_003).unwrap(),
    366         &build(),
    367     )
    368     .await
    369     .expect("candidate startup");
    370     let replay = writer
    371         .repository()
    372         .apply_configuration(
    373             &candidate,
    374             &candidate,
    375             MigrationAppliedAtUnixSeconds::new(1_725_000_004).unwrap(),
    376             &build(),
    377         )
    378         .await
    379         .expect("exact replay is an idempotent no-op");
    380     assert_eq!(replay.generation(), 2);
    381     assert_eq!(replay.revoked_connection_count(), 0);
    382     assert_eq!(replay.revoked_challenge_count(), 0);
    383     writer.close().await.expect("close candidate");
    384 
    385     let inspection = open_myc_state_inspection(&runtime, &candidate_metadata)
    386         .await
    387         .expect("inspection");
    388     let mode = inspection
    389         .repository()
    390         .apply_configuration(
    391             &candidate,
    392             &current,
    393             MigrationAppliedAtUnixSeconds::new(1_725_000_004).unwrap(),
    394             &build(),
    395         )
    396         .await
    397         .expect_err("inspection cannot apply");
    398     assert_eq!(mode.kind(), MycConfigApplyErrorKind::InvalidMode);
    399     inspection.close().await.expect("inspection close");
    400 
    401     let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime))
    402         .await
    403         .expect("inspect database");
    404     let rows = sqlx::query(
    405         "SELECT generation, normalized_config_sha256 FROM myc_config_bindings ORDER BY generation",
    406     )
    407     .fetch_all(&mut connection)
    408     .await
    409     .expect("binding history");
    410     assert_eq!(rows.len(), 2);
    411     assert_eq!(rows[0].get::<i64, _>(0), 1);
    412     assert_eq!(rows[1].get::<i64, _>(0), 2);
    413     assert_eq!(
    414         rows[0].get::<Vec<u8>, _>(1),
    415         current_metadata.configuration_digest().as_bytes()
    416     );
    417     assert_eq!(
    418         rows[1].get::<Vec<u8>, _>(1),
    419         candidate_metadata.configuration_digest().as_bytes()
    420     );
    421     let birth = sqlx::query_scalar::<_, Vec<u8>>(
    422         "SELECT normalized_config_sha256 FROM myc_state_metadata WHERE singleton = 1",
    423     )
    424     .fetch_one(&mut connection)
    425     .await
    426     .expect("birth binding");
    427     assert_eq!(birth, current_metadata.configuration_digest().as_bytes());
    428     let persisted = sqlx::query(
    429         "SELECT service_version, service_commit, lib_revision, rust_version, target, \
    430          feature_profile FROM myc_config_bindings ORDER BY generation",
    431     )
    432     .fetch_all(&mut connection)
    433     .await
    434     .expect("safe binding evidence");
    435     assert_eq!(persisted.len(), 2);
    436     let database_bytes = fs::read(runtime.artifacts().state_database()).expect("database bytes");
    437     for forbidden in [
    438         "wss://relay-primary.example.test/",
    439         "encrypted_file",
    440         "transport.key",
    441         directory.path().to_str().expect("UTF-8 temporary path"),
    442     ] {
    443         assert!(
    444             !database_bytes
    445                 .windows(forbidden.len())
    446                 .any(|window| window == forbidden.as_bytes()),
    447             "configuration history persisted forbidden source material"
    448         );
    449     }
    450     connection.close().await.expect("close database");
    451 }
    452 
    453 #[tokio::test]
    454 async fn v9_upgrade_seeds_one_current_binding_without_rewriting_birth_evidence() {
    455     let directory = tempfile::tempdir().expect("root");
    456     let runtime = runtime(directory.path());
    457     prepare(&runtime);
    458     let current = configuration(CONFIG);
    459     let current_metadata = metadata(&runtime, &current);
    460     initialize_v9(&runtime, &current_metadata).await;
    461 
    462     let writer = open_myc_state_read_write(
    463         &runtime,
    464         &current_metadata,
    465         MigrationAppliedAtUnixSeconds::new(1_725_000_010).unwrap(),
    466         &build(),
    467     )
    468     .await
    469     .expect("upgrade to current schema");
    470     writer.close().await.expect("close upgraded writer");
    471 
    472     let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime))
    473         .await
    474         .expect("inspect upgrade");
    475     let birth_version = sqlx::query_scalar::<_, i64>(
    476         "SELECT state_contract_version FROM myc_state_metadata WHERE singleton = 1",
    477     )
    478     .fetch_one(&mut connection)
    479     .await
    480     .expect("birth version");
    481     assert_eq!(birth_version, 9);
    482     let binding = sqlx::query(
    483         "SELECT generation, state_contract_version, applied_at_unix_s, \
    484          normalized_config_sha256 FROM myc_config_bindings",
    485     )
    486     .fetch_one(&mut connection)
    487     .await
    488     .expect("seed binding");
    489     assert_eq!(binding.get::<i64, _>("generation"), 1);
    490     assert_eq!(
    491         binding.get::<i64, _>("state_contract_version"),
    492         i64::from(myc::MYC_STATE_SCHEMA_VERSION)
    493     );
    494     assert_eq!(binding.get::<i64, _>("applied_at_unix_s"), 1_725_000_010);
    495     assert_eq!(
    496         binding.get::<Vec<u8>, _>("normalized_config_sha256"),
    497         current_metadata.configuration_digest().as_bytes()
    498     );
    499     connection.close().await.expect("close inspection");
    500 }
    501 
    502 #[tokio::test]
    503 async fn v10_binding_remains_valid_historical_evidence_after_v12_migration() {
    504     let directory = tempfile::tempdir().expect("root");
    505     let runtime = runtime(directory.path());
    506     prepare(&runtime);
    507     let current = configuration(CONFIG);
    508     let current_metadata = metadata(&runtime, &current);
    509     initialize_v10(&runtime, &current_metadata).await;
    510 
    511     let writer = open_myc_state_read_write(
    512         &runtime,
    513         &current_metadata,
    514         MigrationAppliedAtUnixSeconds::new(1_725_000_011).unwrap(),
    515         &build(),
    516     )
    517     .await
    518     .expect("v10 binding survives schema-only migration");
    519     writer
    520         .repository()
    521         .verify_binding()
    522         .await
    523         .expect("historical binding verifies");
    524     writer.close().await.expect("close upgraded writer");
    525 
    526     let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime))
    527         .await
    528         .expect("inspect upgrade");
    529     assert_eq!(
    530         sqlx::query_scalar::<_, i64>(
    531             "SELECT state_schema_version FROM radroots_service_metadata WHERE singleton = 1",
    532         )
    533         .fetch_one(&mut connection)
    534         .await
    535         .expect("shared schema version"),
    536         12
    537     );
    538     assert_eq!(
    539         sqlx::query_scalar::<_, i64>(
    540             "SELECT state_contract_version FROM myc_state_metadata WHERE singleton = 1",
    541         )
    542         .fetch_one(&mut connection)
    543         .await
    544         .expect("birth state version"),
    545         10
    546     );
    547     assert_eq!(
    548         sqlx::query_scalar::<_, i64>(
    549             "SELECT state_contract_version FROM myc_config_bindings WHERE generation = 1",
    550         )
    551         .fetch_one(&mut connection)
    552         .await
    553         .expect("historical config state version"),
    554         10
    555     );
    556     assert_eq!(
    557         sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM myc_config_bindings")
    558             .fetch_one(&mut connection)
    559             .await
    560             .expect("binding count"),
    561         1
    562     );
    563     connection.close().await.expect("close inspection");
    564 }
    565 
    566 #[tokio::test]
    567 async fn relay_change_is_blocked_by_nonterminal_work_but_safe_addition_is_admitted() {
    568     let directory = tempfile::tempdir().expect("root");
    569     let runtime = runtime(directory.path());
    570     prepare(&runtime);
    571     let current = configuration(CONFIG);
    572     let current_metadata = metadata(&runtime, &current);
    573     initialize(&runtime, &current_metadata).await;
    574 
    575     let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime))
    576         .await
    577         .expect("database");
    578     sqlx::query(
    579         "INSERT INTO nip46_requests (operation_id, correlation_id, operation_nonce, \
    580          request_identity_sha256, client_public_key, request_id, first_event_id, \
    581          method, request_sha256, received_at_unix_ms) VALUES (?, ?, ?, ?, ?, \
    582          'config-lifecycle-job', ?, 'ping', ?, 1)",
    583     )
    584     .bind([0x12_u8; 32].as_slice())
    585     .bind([0x21_u8; 32].as_slice())
    586     .bind([0x22_u8; 32].as_slice())
    587     .bind([0x23_u8; 32].as_slice())
    588     .bind("7777777777777777777777777777777777777777777777777777777777777777")
    589     .bind([0x24_u8; 32].as_slice())
    590     .bind([0x25_u8; 32].as_slice())
    591     .execute(&mut connection)
    592     .await
    593     .expect("request source");
    594     sqlx::query(
    595         "INSERT INTO delivery_jobs (job_id, source_kind, source_id, artifact_sha256, \
    596          policy_mode, required_acknowledgements, max_attempts, initial_backoff_ms, \
    597          maximum_backoff_ms, attempt_deadline_ms, status, created_at_unix_ms, \
    598          updated_at_unix_ms, finalized_at_unix_ms) VALUES (?, 'signer_response', ?, ?, \
    599          'all_required', 1, 2, 1, 2, 2, 'pending', 1, 1, NULL)",
    600     )
    601     .bind([0x11_u8; 32].as_slice())
    602     .bind([0x12_u8; 32].as_slice())
    603     .bind([0x13_u8; 32].as_slice())
    604     .execute(&mut connection)
    605     .await
    606     .expect("job");
    607     sqlx::query(
    608         "INSERT INTO delivery_targets (job_id, target_index, relay_id, required, \
    609          attempt_count, status, active_attempt_id, next_attempt_at_unix_ms, \
    610          updated_at_unix_ms) VALUES (?, 0, 'primary', 1, 0, 'pending', NULL, NULL, 1)",
    611     )
    612     .bind([0x11_u8; 32].as_slice())
    613     .execute(&mut connection)
    614     .await
    615     .expect("target");
    616     connection.close().await.expect("close database");
    617 
    618     let writer = open_myc_state_read_write(
    619         &runtime,
    620         &current_metadata,
    621         MigrationAppliedAtUnixSeconds::new(1_725_000_001).unwrap(),
    622         &build(),
    623     )
    624     .await
    625     .expect("writer");
    626     let changed = configuration(&CONFIG.replace(
    627         "wss://relay-primary.example.test/",
    628         "wss://relay-primary-next.example.test/",
    629     ));
    630     let conflict = writer
    631         .repository()
    632         .apply_configuration(
    633             &current,
    634             &changed,
    635             MigrationAppliedAtUnixSeconds::new(1_725_000_002).unwrap(),
    636             &build(),
    637         )
    638         .await
    639         .expect_err("retained job blocks relay mutation");
    640     assert_eq!(conflict.kind(), MycConfigApplyErrorKind::PolicyConflict);
    641 
    642     let authentication_changed = configuration(&CONFIG.replacen(
    643         "authentication = \"required\"",
    644         "authentication = \"disabled\"",
    645         1,
    646     ));
    647     let conflict = writer
    648         .repository()
    649         .apply_configuration(
    650             &current,
    651             &authentication_changed,
    652             MigrationAppliedAtUnixSeconds::new(1_725_000_002).unwrap(),
    653             &build(),
    654         )
    655         .await
    656         .expect_err("retained job blocks relay authentication mutation");
    657     assert_eq!(conflict.kind(), MycConfigApplyErrorKind::PolicyConflict);
    658 
    659     let addition_source = CONFIG.replace(
    660         "[transport]\n",
    661         "[[relays]]\nid = \"tertiary\"\nurl = \"wss://relay-tertiary.example.test/\"\nread = true\nwrite = true\nrequired = false\nauthentication = \"required\"\n\n[transport]\n",
    662     );
    663     let addition = configuration(&addition_source);
    664     let outcome = writer
    665         .repository()
    666         .apply_configuration(
    667             &current,
    668             &addition,
    669             MigrationAppliedAtUnixSeconds::new(1_725_000_003).unwrap(),
    670             &build(),
    671         )
    672         .await
    673         .expect("safe relay addition");
    674     assert_eq!(outcome.generation(), 2);
    675     writer.close().await.expect("close");
    676 }
    677 
    678 #[tokio::test]
    679 async fn identity_change_atomically_revokes_live_connections_and_pending_challenges() {
    680     let directory = tempfile::tempdir().expect("root");
    681     let runtime = runtime(directory.path());
    682     prepare(&runtime);
    683     let current = configuration(CONFIG);
    684     let current_metadata = metadata(&runtime, &current);
    685     initialize(&runtime, &current_metadata).await;
    686 
    687     let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime))
    688         .await
    689         .expect("database");
    690     for (id, status) in [(0x31_u8, "active"), (0x32_u8, "pending")] {
    691         sqlx::query(
    692             "INSERT INTO connections (connection_id, connection_nonce, client_public_key, \
    693              requested_permissions_sha256, policy_generation, status, created_at_unix_ms, \
    694              updated_at_unix_ms, authorized_until_unix_ms) VALUES (?, ?, ?, ?, 1, ?, 1, 1, ?)",
    695         )
    696         .bind([id; 32].as_slice())
    697         .bind([id.saturating_add(16); 32].as_slice())
    698         .bind("7777777777777777777777777777777777777777777777777777777777777777")
    699         .bind([0x41_u8; 32].as_slice())
    700         .bind(status)
    701         .bind((status == "active").then_some(10_000_i64))
    702         .execute(&mut connection)
    703         .await
    704         .expect("connection");
    705     }
    706     sqlx::query(
    707         "INSERT INTO nip46_requests (operation_id, correlation_id, operation_nonce, \
    708          request_identity_sha256, client_public_key, request_id, first_event_id, method, \
    709          request_sha256, received_at_unix_ms) VALUES (?, ?, ?, ?, ?, 'identity-change', ?, \
    710          'connect', ?, 1)",
    711     )
    712     .bind([0x33_u8; 32].as_slice())
    713     .bind([0x34_u8; 32].as_slice())
    714     .bind([0x35_u8; 32].as_slice())
    715     .bind([0x36_u8; 32].as_slice())
    716     .bind("7777777777777777777777777777777777777777777777777777777777777777")
    717     .bind([0x37_u8; 32].as_slice())
    718     .bind([0x38_u8; 32].as_slice())
    719     .execute(&mut connection)
    720     .await
    721     .expect("request");
    722     sqlx::query(
    723         "INSERT INTO connection_auth_challenges (challenge_id, challenge_nonce, \
    724          connection_id, operation_id, policy_generation, challenge_url, state, \
    725          issued_at_unix_ms, expires_at_unix_ms, resolved_at_unix_ms) \
    726          VALUES (?, ?, ?, ?, 1, 'https://myc.example.test/challenge', 'pending', 1, 10000, NULL)",
    727     )
    728     .bind([0x39_u8; 32].as_slice())
    729     .bind([0x3a_u8; 32].as_slice())
    730     .bind([0x31_u8; 32].as_slice())
    731     .bind([0x33_u8; 32].as_slice())
    732     .execute(&mut connection)
    733     .await
    734     .expect("challenge");
    735     connection.close().await.expect("close database");
    736 
    737     let candidate = configuration(&CONFIG.replace(
    738         "expected_public_key = \"2222222222222222222222222222222222222222222222222222222222222222\"",
    739         "expected_public_key = \"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\"",
    740     ));
    741     let writer = open_myc_state_read_write(
    742         &runtime,
    743         &current_metadata,
    744         MigrationAppliedAtUnixSeconds::new(1_725_000_001).unwrap(),
    745         &build(),
    746     )
    747     .await
    748     .expect("writer");
    749     let outcome = writer
    750         .repository()
    751         .apply_configuration(
    752             &current,
    753             &candidate,
    754             MigrationAppliedAtUnixSeconds::new(1_725_000_002).unwrap(),
    755             &build(),
    756         )
    757         .await
    758         .expect("identity apply");
    759     assert_eq!(outcome.revoked_connection_count(), 2);
    760     assert_eq!(outcome.revoked_challenge_count(), 1);
    761     writer.close().await.expect("close writer");
    762 
    763     let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime))
    764         .await
    765         .expect("inspect");
    766     let statuses =
    767         sqlx::query_scalar::<_, String>("SELECT status FROM connections ORDER BY connection_id")
    768             .fetch_all(&mut connection)
    769             .await
    770             .expect("connection statuses");
    771     assert_eq!(statuses, ["expired", "denied"]);
    772     let state = sqlx::query_scalar::<_, String>(
    773         "SELECT state FROM connection_auth_challenges WHERE challenge_id = ?",
    774     )
    775     .bind([0x39_u8; 32].as_slice())
    776     .fetch_one(&mut connection)
    777     .await
    778     .expect("challenge state");
    779     assert_eq!(state, "expired");
    780     connection.close().await.expect("close inspect");
    781 }
    782 
    783 #[tokio::test]
    784 async fn permission_narrowing_revokes_only_connections_with_removed_grants() {
    785     let directory = tempfile::tempdir().expect("root");
    786     let runtime = runtime(directory.path());
    787     prepare(&runtime);
    788     let current = configuration(CONFIG);
    789     let current_metadata = metadata(&runtime, &current);
    790     initialize(&runtime, &current_metadata).await;
    791 
    792     let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime))
    793         .await
    794         .expect("database");
    795     for (id, permission) in [(0x51_u8, "sign_event:kind:1"), (0x52_u8, "nip04_encrypt")] {
    796         sqlx::query(
    797             "INSERT INTO connections (connection_id, connection_nonce, client_public_key, \
    798              requested_permissions_sha256, policy_generation, status, created_at_unix_ms, \
    799              updated_at_unix_ms, authorized_until_unix_ms) VALUES (?, ?, ?, ?, 1, \
    800              'active', 1, 1, 10000)",
    801         )
    802         .bind([id; 32].as_slice())
    803         .bind([id.saturating_add(16); 32].as_slice())
    804         .bind("7777777777777777777777777777777777777777777777777777777777777777")
    805         .bind([id.saturating_add(32); 32].as_slice())
    806         .execute(&mut connection)
    807         .await
    808         .expect("connection");
    809         sqlx::query(
    810             "INSERT INTO connection_permissions (connection_id, permission_scope, \
    811              permission_code) VALUES (?, 'granted', ?)",
    812         )
    813         .bind([id; 32].as_slice())
    814         .bind(permission)
    815         .execute(&mut connection)
    816         .await
    817         .expect("permission");
    818     }
    819     connection.close().await.expect("close database");
    820 
    821     let candidate_source = CONFIG
    822         .replace(
    823             "permission_ceiling = [\"nip04_decrypt\", \"nip04_encrypt\", \"nip44_decrypt\", \"nip44_encrypt\", \"sign_event:kind:1\"]",
    824             "permission_ceiling = [\"nip04_decrypt\", \"nip04_encrypt\", \"nip44_decrypt\", \"nip44_encrypt\", \"sign_event:kind:2\"]",
    825         )
    826         .replace("allowed_sign_event_kinds = [1]", "allowed_sign_event_kinds = [2]");
    827     let candidate = configuration(&candidate_source);
    828     let writer = open_myc_state_read_write(
    829         &runtime,
    830         &current_metadata,
    831         MigrationAppliedAtUnixSeconds::new(1_725_000_001).unwrap(),
    832         &build(),
    833     )
    834     .await
    835     .expect("writer");
    836     let outcome = writer
    837         .repository()
    838         .apply_configuration(
    839             &current,
    840             &candidate,
    841             MigrationAppliedAtUnixSeconds::new(1_725_000_002).unwrap(),
    842             &build(),
    843         )
    844         .await
    845         .expect("narrowing apply");
    846     assert_eq!(outcome.revoked_connection_count(), 1);
    847     writer.close().await.expect("close writer");
    848 
    849     let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime))
    850         .await
    851         .expect("inspect");
    852     let rows = sqlx::query("SELECT connection_id, status FROM connections ORDER BY connection_id")
    853         .fetch_all(&mut connection)
    854         .await
    855         .expect("statuses");
    856     assert_eq!(rows[0].get::<String, _>(1), "expired");
    857     assert_eq!(rows[1].get::<String, _>(1), "active");
    858     connection.close().await.expect("close inspect");
    859 }
    860 
    861 #[tokio::test]
    862 async fn exact_history_capacity_fails_with_resource_exhausted_without_mutation() {
    863     let directory = tempfile::tempdir().expect("root");
    864     let runtime = runtime(directory.path());
    865     prepare(&runtime);
    866     let current = configuration(CONFIG);
    867     let current_metadata = metadata(&runtime, &current);
    868     initialize(&runtime, &current_metadata).await;
    869 
    870     let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime))
    871         .await
    872         .expect("database");
    873     sqlx::query(
    874         "WITH RECURSIVE generation(value) AS (VALUES(2) UNION ALL \
    875          SELECT value + 1 FROM generation WHERE value < 1024) \
    876          INSERT INTO myc_config_bindings (generation, normalized_config_sha256, \
    877          transport_public_key, user_public_key, discovery_public_key, \
    878          config_contract_version, state_contract_version, operator_contract_version, \
    879          status_contract_version, applied_at_unix_s, service_version, service_commit, \
    880          lib_revision, rust_version, target, feature_profile, provider_contract_version) \
    881          SELECT generation.value, binding.normalized_config_sha256, \
    882          binding.transport_public_key, binding.user_public_key, binding.discovery_public_key, \
    883          binding.config_contract_version, binding.state_contract_version, \
    884          binding.operator_contract_version, binding.status_contract_version, \
    885          binding.applied_at_unix_s, binding.service_version, binding.service_commit, \
    886          binding.lib_revision, binding.rust_version, binding.target, binding.feature_profile, \
    887          binding.provider_contract_version FROM generation \
    888          CROSS JOIN myc_config_bindings AS binding WHERE binding.generation = 1",
    889     )
    890     .execute(&mut connection)
    891     .await
    892     .expect("fill bounded history");
    893     connection.close().await.expect("close database");
    894 
    895     let writer = open_myc_state_read_write(
    896         &runtime,
    897         &current_metadata,
    898         MigrationAppliedAtUnixSeconds::new(1_725_000_001).unwrap(),
    899         &build(),
    900     )
    901     .await
    902     .expect("writer");
    903     let candidate = configuration(&CONFIG.replace("level = \"info\"", "level = \"warn\""));
    904     let error = writer
    905         .repository()
    906         .apply_configuration(
    907             &current,
    908             &candidate,
    909             MigrationAppliedAtUnixSeconds::new(1_725_000_002).unwrap(),
    910             &build(),
    911         )
    912         .await
    913         .expect_err("full history");
    914     assert_eq!(error.kind(), MycConfigApplyErrorKind::ResourceExhausted);
    915     assert_eq!(error.code(), "resource_exhausted");
    916     assert!(Error::source(&error).is_none());
    917     writer.close().await.expect("close writer");
    918 
    919     let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime))
    920         .await
    921         .expect("inspect");
    922     let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM myc_config_bindings")
    923         .fetch_one(&mut connection)
    924         .await
    925         .expect("count");
    926     assert_eq!(count, 1024);
    927     connection.close().await.expect("close inspect");
    928 }
    929 
    930 #[test]
    931 fn configuration_lifecycle_surface_is_sealed_and_diagnostics_are_safe() {
    932     assert!(LIB_SOURCE.contains("mod state_config;"));
    933     assert!(!LIB_SOURCE.contains("pub mod state_config;"));
    934     for forbidden in [
    935         "pub transaction:",
    936         "pub connection:",
    937         "pub pool:",
    938         "credential_reference",
    939         "envelope_path",
    940         "relay_url TEXT",
    941         "DELETE FROM myc_config_bindings",
    942         "UPDATE myc_config_bindings",
    943     ] {
    944         assert!(
    945             !CONFIG_SOURCE.contains(forbidden),
    946             "forbidden configuration-history surface `{forbidden}`"
    947         );
    948     }
    949     for kind in [
    950         MycConfigApplyErrorKind::InvalidMode,
    951         MycConfigApplyErrorKind::InvalidInput,
    952         MycConfigApplyErrorKind::Binding,
    953         MycConfigApplyErrorKind::PolicyConflict,
    954         MycConfigApplyErrorKind::ResourceExhausted,
    955         MycConfigApplyErrorKind::Transaction,
    956         MycConfigApplyErrorKind::CommitOutcomeUnknown,
    957     ] {
    958         let rendered = format!("{kind:?} {}", kind.code());
    959         for secret in ["relay-primary", "credential", "state.sqlite", "/var/lib"] {
    960             assert!(!rendered.contains(secret));
    961         }
    962     }
    963     let error = MycConfigApplyErrorKind::PolicyConflict;
    964     assert_eq!(error.code(), "config_apply_policy_conflict");
    965     let _source_free: fn(&myc::MycConfigApplyError) -> Option<&(dyn Error + 'static)> =
    966         Error::source;
    967 }