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, ¤t); 297 initialize(&runtime, ¤t_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 ¤t_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 ¤t_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 ¤t, 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 ¤t, 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 ¤t_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 ¤t, 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, ¤t); 460 initialize_v9(&runtime, ¤t_metadata).await; 461 462 let writer = open_myc_state_read_write( 463 &runtime, 464 ¤t_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, ¤t); 509 initialize_v10(&runtime, ¤t_metadata).await; 510 511 let writer = open_myc_state_read_write( 512 &runtime, 513 ¤t_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, ¤t); 573 initialize(&runtime, ¤t_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 ¤t_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 ¤t, 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 ¤t, 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 ¤t, 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, ¤t); 685 initialize(&runtime, ¤t_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 ¤t_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 ¤t, 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, ¤t); 790 initialize(&runtime, ¤t_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 ¤t_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 ¤t, 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, ¤t); 868 initialize(&runtime, ¤t_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 ¤t_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 ¤t, 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 }