myc

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

services_hardening_signer_request_state.rs (25064B)


      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_NIP46_CANONICAL_REQUEST_MAX_BYTES, MYC_NIP46_REQUEST_ID_MAX_UTF8_BYTES,
      8     MYC_STATE_SCHEMA_VERSION, MycConfigProfile, MycNip46ClientPublicKey, MycNip46EventId,
      9     MycNip46RequestId, MycRequestReceivedAtUnixMs, MycSignerOperationNonce, MycSignerRequest,
     10     MycSignerRequestAdmission, MycSignerRequestDigest, MycSignerRequestErrorKind,
     11     MycSignerRequestMethod, MycStateMetadata, RadrootsHostEnvironment, RadrootsPathResolver,
     12     RadrootsPlatform, initialize_myc_state, open_myc_state_read_write, parse_myc_cli_v1_from,
     13     parse_myc_config_v1, resolve_myc_runtime_context,
     14 };
     15 use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity};
     16 use radroots_storage::event::SourceGeneration;
     17 use sqlx::{ConnectOptions, Connection, sqlite::SqliteConnectOptions};
     18 
     19 const CONFIG_EXAMPLE: &[u8] =
     20     include_bytes!("../contracts/services_hardening/config.v1.example.toml");
     21 const REQUEST_SOURCE: &str = include_str!("../src/state_request.rs");
     22 const CATALOG_SOURCE: &str = include_str!("../src/state_catalog.rs");
     23 const CLIENT_PUBLIC_KEY: &str = "2222222222222222222222222222222222222222222222222222222222222222";
     24 const REQUEST_BYTES: &[u8] = b"{\"id\":\"request-01\",\"method\":\"ping\",\"params\":[]}";
     25 
     26 fn runtime(root: &Path) -> myc::MycRuntimeContext {
     27     let root = root.to_str().expect("UTF-8 temporary root");
     28     let invocation = parse_myc_cli_v1_from([
     29         "myc",
     30         "--profile",
     31         "repo-local",
     32         "--instance",
     33         "primary",
     34         "--repo-local-root",
     35         root,
     36         "run",
     37     ])
     38     .expect("valid test invocation");
     39     resolve_myc_runtime_context(
     40         &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()),
     41         &invocation,
     42     )
     43     .expect("runtime context")
     44 }
     45 
     46 fn prepare_state_directory(runtime: &myc::MycRuntimeContext) {
     47     let directory = runtime.context().paths().state();
     48     fs::create_dir_all(directory).expect("state directory");
     49     fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).expect("state mode");
     50 }
     51 
     52 fn metadata(runtime: &myc::MycRuntimeContext) -> MycStateMetadata {
     53     let configuration =
     54         parse_myc_config_v1(CONFIG_EXAMPLE, MycConfigProfile::RepoLocal).expect("configuration");
     55     MycStateMetadata::new(
     56         runtime,
     57         &configuration,
     58         SourceGeneration::new([0x5a; 32]).expect("generation"),
     59         1_725_000_000_000,
     60     )
     61     .expect("metadata")
     62 }
     63 
     64 fn migration_evidence() -> (MigrationAppliedAtUnixSeconds, MigrationBuildIdentity) {
     65     let applied_at = MigrationAppliedAtUnixSeconds::new(1_725_000_000).expect("migration time");
     66     let build = MigrationBuildIdentity::new(
     67         env!("CARGO_PKG_VERSION"),
     68         "1111111111111111111111111111111111111111",
     69         "053d0c750bf9cd683c6ea37cefe7e79617ba629f",
     70         "rustc-test",
     71         "test-target",
     72         "service-host",
     73         1,
     74         MYC_STATE_SCHEMA_VERSION,
     75         1,
     76         1,
     77         1,
     78     )
     79     .expect("build identity");
     80     (applied_at, build)
     81 }
     82 
     83 fn request(
     84     request_id: &str,
     85     event_byte: u8,
     86     method: MycSignerRequestMethod,
     87     bytes: &[u8],
     88     nonce_byte: u8,
     89     received_at: u64,
     90 ) -> MycSignerRequest {
     91     MycSignerRequest::new(
     92         MycNip46ClientPublicKey::new(CLIENT_PUBLIC_KEY).expect("client identity"),
     93         MycNip46RequestId::new(request_id).expect("request ID"),
     94         MycNip46EventId::from_bytes([event_byte; 32]),
     95         method,
     96         MycSignerRequestDigest::for_canonical_request(bytes).expect("request digest"),
     97         MycSignerOperationNonce::from_injected_entropy([nonce_byte; 32]),
     98         MycRequestReceivedAtUnixMs::new(received_at).expect("received time"),
     99     )
    100 }
    101 
    102 fn hex(bytes: &[u8]) -> String {
    103     bytes.iter().map(|byte| format!("{byte:02x}")).collect()
    104 }
    105 
    106 #[test]
    107 fn request_inputs_ids_methods_and_diagnostics_are_closed_bounded_and_stable() {
    108     let maximum_id = "a".repeat(MYC_NIP46_REQUEST_ID_MAX_UTF8_BYTES);
    109     assert!(MycNip46RequestId::new(&maximum_id).is_ok());
    110     for invalid in [
    111         "",
    112         " request",
    113         "request ",
    114         "request\n",
    115         &"a".repeat(MYC_NIP46_REQUEST_ID_MAX_UTF8_BYTES + 1),
    116         &"x".repeat(1024 * 1024),
    117     ] {
    118         assert_eq!(
    119             MycNip46RequestId::new(invalid)
    120                 .expect_err("invalid request ID")
    121                 .kind(),
    122             MycSignerRequestErrorKind::InvalidRequestId
    123         );
    124     }
    125 
    126     assert!(MycNip46ClientPublicKey::new(CLIENT_PUBLIC_KEY).is_ok());
    127     for invalid in [
    128         "22",
    129         "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
    130         "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
    131     ] {
    132         assert_eq!(
    133             MycNip46ClientPublicKey::new(invalid)
    134                 .expect_err("invalid client identity")
    135                 .kind(),
    136             MycSignerRequestErrorKind::InvalidClientIdentity
    137         );
    138     }
    139 
    140     let maximum_request = vec![b'x'; MYC_NIP46_CANONICAL_REQUEST_MAX_BYTES];
    141     assert!(MycSignerRequestDigest::for_canonical_request(&maximum_request).is_ok());
    142     assert_eq!(
    143         MycSignerRequestDigest::for_canonical_request(&[])
    144             .expect_err("empty request")
    145             .kind(),
    146         MycSignerRequestErrorKind::InvalidCanonicalRequest
    147     );
    148     assert_eq!(
    149         MycSignerRequestDigest::for_canonical_request(&vec![
    150             b'x';
    151             MYC_NIP46_CANONICAL_REQUEST_MAX_BYTES
    152                 + 1
    153         ])
    154         .expect_err("oversized request")
    155         .kind(),
    156         MycSignerRequestErrorKind::InvalidCanonicalRequest
    157     );
    158 
    159     assert!(MycRequestReceivedAtUnixMs::new(i64::MAX.unsigned_abs()).is_ok());
    160     for invalid in [0, i64::MAX.unsigned_abs() + 1] {
    161         assert_eq!(
    162             MycRequestReceivedAtUnixMs::new(invalid)
    163                 .expect_err("invalid time")
    164                 .kind(),
    165             MycSignerRequestErrorKind::InvalidReceivedAt
    166         );
    167     }
    168 
    169     let methods = [
    170         (MycSignerRequestMethod::Connect, "connect"),
    171         (MycSignerRequestMethod::GetPublicKey, "get_public_key"),
    172         (
    173             MycSignerRequestMethod::GetSessionCapability,
    174             "get_session_capability",
    175         ),
    176         (MycSignerRequestMethod::SignEvent, "sign_event"),
    177         (MycSignerRequestMethod::Nip04Encrypt, "nip04_encrypt"),
    178         (MycSignerRequestMethod::Nip04Decrypt, "nip04_decrypt"),
    179         (MycSignerRequestMethod::Nip44Encrypt, "nip44_encrypt"),
    180         (MycSignerRequestMethod::Nip44Decrypt, "nip44_decrypt"),
    181         (MycSignerRequestMethod::Ping, "ping"),
    182         (MycSignerRequestMethod::SwitchRelays, "switch_relays"),
    183         (MycSignerRequestMethod::Logout, "logout"),
    184     ];
    185     assert_eq!(
    186         methods.map(|(method, _)| method.as_str()),
    187         methods.map(|(_, wire)| wire)
    188     );
    189 
    190     assert_eq!(
    191         hex(MycSignerRequestDigest::for_canonical_request(REQUEST_BYTES)
    192             .expect("digest")
    193             .as_bytes()),
    194         "378d4f7906aed41e5af96c7000dfc57d9c4c4c9ad3d94b84e985621c25e727f2"
    195     );
    196     let nonce = MycSignerOperationNonce::from_injected_entropy([0x5a; 32]);
    197     assert_eq!(format!("{nonce:?}"), "MycSignerOperationNonce([redacted])");
    198 
    199     let error = MycNip46RequestId::new(" secret\n").expect_err("invalid input");
    200     assert!(Error::source(&error).is_none());
    201     let request = request(
    202         "request-01",
    203         0x44,
    204         MycSignerRequestMethod::Ping,
    205         REQUEST_BYTES,
    206         1,
    207         1,
    208     );
    209     let rendered = format!("{request:?} {error} {error:?}");
    210     for secret in [CLIENT_PUBLIC_KEY, "request-01", "secret", "378d4f79"] {
    211         assert!(!rendered.contains(secret));
    212     }
    213 }
    214 
    215 #[tokio::test]
    216 async fn request_admission_is_atomic_idempotent_conflict_aware_and_restart_stable() {
    217     let directory = tempfile::tempdir().expect("temporary root");
    218     let runtime = runtime(directory.path());
    219     prepare_state_directory(&runtime);
    220     let metadata = metadata(&runtime);
    221     let (applied_at, build) = migration_evidence();
    222     initialize_myc_state(&runtime, &metadata, applied_at, &build)
    223         .await
    224         .expect("state initialization");
    225     let host = open_myc_state_read_write(&runtime, &metadata, applied_at, &build)
    226         .await
    227         .expect("writable host");
    228 
    229     let first = request(
    230         "request-01",
    231         0x44,
    232         MycSignerRequestMethod::Ping,
    233         REQUEST_BYTES,
    234         100,
    235         100,
    236     );
    237     let admitted = host
    238         .repository()
    239         .admit_signer_request(&first)
    240         .await
    241         .expect("first admission");
    242     assert!(matches!(admitted, MycSignerRequestAdmission::Admitted(_)));
    243     assert_eq!(admitted.record().replay_count(), 0);
    244     assert_eq!(admitted.record().conflict_count(), 0);
    245     assert_eq!(
    246         hex(admitted.record().operation_id().as_bytes()),
    247         "c72e6b8e1824b94e8ce6284cff532895c3e8b80c7859e02decfc7fda88134444"
    248     );
    249     assert_eq!(
    250         hex(admitted.record().correlation_id().as_bytes()),
    251         "87d551e4c196fded2fa4d9335655711f64da873c552e7338f8e99541eb871654"
    252     );
    253     assert_ne!(
    254         admitted.record().operation_id().as_bytes(),
    255         admitted.record().correlation_id().as_bytes()
    256     );
    257     let first_operation_id = admitted.record().operation_id();
    258     let first_correlation_id = admitted.record().correlation_id();
    259 
    260     let same_event = request(
    261         "request-01",
    262         0x44,
    263         MycSignerRequestMethod::Ping,
    264         REQUEST_BYTES,
    265         101,
    266         101,
    267     );
    268     let replay = host
    269         .repository()
    270         .admit_signer_request(&same_event)
    271         .await
    272         .expect("same-event replay");
    273     assert!(matches!(replay, MycSignerRequestAdmission::ExactReplay(_)));
    274     assert_eq!(replay.record().replay_count(), 1);
    275     assert_eq!(replay.record().operation_id(), first_operation_id);
    276     assert_eq!(replay.record().correlation_id(), first_correlation_id);
    277 
    278     let new_event = request(
    279         "request-01",
    280         0x45,
    281         MycSignerRequestMethod::Ping,
    282         REQUEST_BYTES,
    283         102,
    284         102,
    285     );
    286     let replay = host
    287         .repository()
    288         .admit_signer_request(&new_event)
    289         .await
    290         .expect("new-event replay");
    291     assert!(matches!(replay, MycSignerRequestAdmission::ExactReplay(_)));
    292     assert_eq!(replay.record().replay_count(), 2);
    293 
    294     let changed_payload = request(
    295         "request-01",
    296         0x46,
    297         MycSignerRequestMethod::GetPublicKey,
    298         b"{\"id\":\"request-01\",\"method\":\"get_public_key\",\"params\":[]}",
    299         103,
    300         103,
    301     );
    302     let conflict = host
    303         .repository()
    304         .admit_signer_request(&changed_payload)
    305         .await
    306         .expect("request conflict");
    307     assert!(matches!(
    308         conflict,
    309         MycSignerRequestAdmission::ConflictingReuse(_)
    310     ));
    311     assert_eq!(conflict.record().method(), MycSignerRequestMethod::Ping);
    312     assert_eq!(conflict.record().conflict_count(), 1);
    313 
    314     let reused_event = request(
    315         "request-02",
    316         0x44,
    317         MycSignerRequestMethod::Ping,
    318         b"{\"id\":\"request-02\",\"method\":\"ping\",\"params\":[]}",
    319         104,
    320         104,
    321     );
    322     let conflict = host
    323         .repository()
    324         .admit_signer_request(&reused_event)
    325         .await
    326         .expect("event conflict");
    327     assert!(matches!(
    328         conflict,
    329         MycSignerRequestAdmission::ConflictingReuse(_)
    330     ));
    331     assert_eq!(conflict.record().operation_id(), first_operation_id);
    332     let rendered = format!("{admitted:?} {:?}", admitted.record());
    333     for secret in [CLIENT_PUBLIC_KEY, "request-01", "c72e6b8e"] {
    334         assert!(!rendered.contains(secret));
    335     }
    336     host.close().await.expect("close after first lifecycle");
    337 
    338     let host = open_myc_state_read_write(&runtime, &metadata, applied_at, &build)
    339         .await
    340         .expect("reopened writable host");
    341     let after_reopen = request(
    342         "request-01",
    343         0x47,
    344         MycSignerRequestMethod::Ping,
    345         REQUEST_BYTES,
    346         105,
    347         105,
    348     );
    349     let replay = host
    350         .repository()
    351         .admit_signer_request(&after_reopen)
    352         .await
    353         .expect("replay after reopen");
    354     assert!(matches!(replay, MycSignerRequestAdmission::ExactReplay(_)));
    355     assert_eq!(replay.record().replay_count(), 3);
    356     assert_eq!(replay.record().conflict_count(), 1);
    357     host.close().await.expect("final close");
    358 
    359     let options = SqliteConnectOptions::new()
    360         .filename(runtime.artifacts().state_database())
    361         .create_if_missing(false)
    362         .read_only(true)
    363         .disable_statement_logging();
    364     let mut connection = sqlx::SqliteConnection::connect_with(&options)
    365         .await
    366         .expect("test inspection connection");
    367     assert_eq!(
    368         sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM nip46_requests")
    369             .fetch_one(&mut connection)
    370             .await
    371             .expect("request count"),
    372         1
    373     );
    374     assert_eq!(
    375         sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM nip46_request_dedup")
    376             .fetch_one(&mut connection)
    377             .await
    378             .expect("dedup count"),
    379         4
    380     );
    381     let event_conflicts = sqlx::query_scalar::<_, i64>(
    382         "SELECT conflict_count FROM nip46_request_dedup \
    383          WHERE dedup_kind = 'event' AND identity_sha256 = ?",
    384     )
    385     .bind([0x44_u8; 32].as_slice())
    386     .fetch_one(&mut connection)
    387     .await
    388     .expect("event conflict count");
    389     assert_eq!(event_conflicts, 1);
    390     assert!(
    391         sqlx::query("UPDATE nip46_requests SET received_at_unix_ms = 999")
    392             .execute(&mut connection)
    393             .await
    394             .is_err()
    395     );
    396     connection.close().await.expect("inspection close");
    397 }
    398 
    399 #[tokio::test]
    400 async fn concurrent_identical_admission_creates_one_request_and_bounded_replay_evidence() {
    401     let directory = tempfile::tempdir().expect("temporary root");
    402     let runtime = runtime(directory.path());
    403     prepare_state_directory(&runtime);
    404     let metadata = metadata(&runtime);
    405     let (applied_at, build) = migration_evidence();
    406     initialize_myc_state(&runtime, &metadata, applied_at, &build)
    407         .await
    408         .expect("state initialization");
    409     let host = open_myc_state_read_write(&runtime, &metadata, applied_at, &build)
    410         .await
    411         .expect("writable host");
    412     let request = request(
    413         "request-concurrent",
    414         0x55,
    415         MycSignerRequestMethod::Ping,
    416         b"concurrent",
    417         200,
    418         200,
    419     );
    420     let repository = host.repository();
    421 
    422     let (a, b, c, d, e, f, g, h) = tokio::join!(
    423         repository.admit_signer_request(&request),
    424         repository.admit_signer_request(&request),
    425         repository.admit_signer_request(&request),
    426         repository.admit_signer_request(&request),
    427         repository.admit_signer_request(&request),
    428         repository.admit_signer_request(&request),
    429         repository.admit_signer_request(&request),
    430         repository.admit_signer_request(&request),
    431     );
    432     let outcomes = [a, b, c, d, e, f, g, h]
    433         .into_iter()
    434         .collect::<Result<Vec<_>, _>>()
    435         .expect("concurrent admissions");
    436     assert_eq!(
    437         outcomes
    438             .iter()
    439             .filter(|outcome| matches!(outcome, MycSignerRequestAdmission::Admitted(_)))
    440             .count(),
    441         1
    442     );
    443     assert_eq!(
    444         outcomes
    445             .iter()
    446             .filter(|outcome| matches!(outcome, MycSignerRequestAdmission::ExactReplay(_)))
    447             .count(),
    448         7
    449     );
    450     let final_record = outcomes
    451         .iter()
    452         .max_by_key(|outcome| outcome.record().replay_count())
    453         .expect("final replay record")
    454         .record();
    455     assert_eq!(final_record.replay_count(), 7);
    456     assert_eq!(final_record.conflict_count(), 0);
    457     host.close().await.expect("host close");
    458 }
    459 
    460 #[tokio::test]
    461 async fn exact_schema_v3_state_advances_to_v12_before_request_admission() {
    462     let directory = tempfile::tempdir().expect("temporary root");
    463     let runtime = runtime(directory.path());
    464     prepare_state_directory(&runtime);
    465     let metadata = metadata(&runtime);
    466     let (applied_at, build) = migration_evidence();
    467     initialize_myc_state(&runtime, &metadata, applied_at, &build)
    468         .await
    469         .expect("state initialization");
    470 
    471     let options = SqliteConnectOptions::new()
    472         .filename(runtime.artifacts().state_database())
    473         .create_if_missing(false)
    474         .disable_statement_logging();
    475     let mut connection = sqlx::SqliteConnection::connect_with(&options)
    476         .await
    477         .expect("downgrade fixture connection");
    478     let shared_update = sqlx::query_scalar::<_, String>(
    479         "SELECT sql FROM sqlite_schema WHERE type = 'trigger' \
    480          AND name = 'radroots_service_metadata_guard_update'",
    481     )
    482     .fetch_one(&mut connection)
    483     .await
    484     .expect("shared update trigger");
    485     let myc_update = sqlx::query_scalar::<_, String>(
    486         "SELECT sql FROM sqlite_schema WHERE type = 'trigger' \
    487          AND name = 'myc_state_metadata_no_update'",
    488     )
    489     .fetch_one(&mut connection)
    490     .await
    491     .expect("Myc update trigger");
    492     let migration_delete = sqlx::query_scalar::<_, String>(
    493         "SELECT sql FROM sqlite_schema WHERE type = 'trigger' \
    494          AND name = 'schema_migrations_no_delete'",
    495     )
    496     .fetch_one(&mut connection)
    497     .await
    498     .expect("migration delete trigger");
    499     for sql in [
    500         "DROP TRIGGER radroots_service_metadata_guard_update",
    501         "DROP TRIGGER myc_state_metadata_no_update",
    502         "DROP TRIGGER schema_migrations_no_delete",
    503         "DROP TRIGGER connection_rate_windows_guard_update",
    504         "DROP TRIGGER nip46_request_audit_no_update",
    505         "DROP TRIGGER operation_audit_no_update",
    506         "DROP TRIGGER myc_audit_state_no_delete",
    507         "DROP TRIGGER myc_audit_state_guard_update",
    508         "DROP TRIGGER connection_auth_challenges_no_delete",
    509         "DROP TRIGGER connection_auth_challenges_guard_update",
    510         "DROP TRIGGER nip46_request_decisions_no_delete",
    511         "DROP TRIGGER nip46_request_decisions_guard_update",
    512         "DROP TRIGGER connection_permissions_no_delete",
    513         "DROP TRIGGER connection_permissions_no_update",
    514         "DROP TRIGGER connections_no_delete",
    515         "DROP TRIGGER connections_guard_update",
    516         "DROP TRIGGER nip46_pending_responses_no_delete",
    517         "DROP TRIGGER nip46_pending_responses_no_update",
    518         "DROP TRIGGER nip46_signed_responses_guard_pending_insert",
    519         "DROP TRIGGER nip46_pending_responses_guard_insert",
    520         "DROP TABLE nip46_pending_responses",
    521         "DROP TRIGGER myc_admin_operations_guard_update",
    522         "DROP TABLE myc_admin_operations",
    523         "DROP TRIGGER myc_config_bindings_no_delete",
    524         "DROP TRIGGER myc_config_bindings_no_update",
    525         "DROP TRIGGER myc_config_bindings_guard_insert",
    526         "DROP TABLE myc_config_bindings",
    527         "DROP TRIGGER nip46_signed_responses_no_delete",
    528         "DROP TRIGGER nip46_signed_responses_no_update",
    529         "DROP TABLE nip46_signed_responses",
    530         "DROP TRIGGER nip46_operation_commits_no_delete",
    531         "DROP TRIGGER nip46_operation_commits_no_update",
    532         "DROP TABLE nip46_operation_commits",
    533         "DROP TABLE discovery_publication_state",
    534         "DROP TABLE discovery_documents",
    535         "DROP TABLE discovery_desired_state",
    536         "DROP TABLE delivery_attempts",
    537         "DROP TABLE delivery_targets",
    538         "DROP TABLE delivery_jobs",
    539         "DROP TABLE nip46_request_audit",
    540         "DROP TABLE operation_audit",
    541         "DROP TABLE connection_rate_windows",
    542         "DROP TABLE myc_audit_state",
    543         "DROP TABLE nip46_request_decisions",
    544         "DROP TABLE connection_auth_challenges",
    545         "DROP TABLE connection_permissions",
    546         "DROP TABLE connections",
    547         "UPDATE radroots_service_metadata SET state_schema_version = 3 WHERE singleton = 1",
    548         "UPDATE myc_state_metadata SET state_contract_version = 3 WHERE singleton = 1",
    549         "DELETE FROM schema_migrations WHERE version IN (4, 5, 6, 7, 8, 9, 10, 11, 12)",
    550     ] {
    551         sqlx::query(sql)
    552             .execute(&mut connection)
    553             .await
    554             .expect("construct exact schema-v3 fixture");
    555     }
    556     for sql in [&shared_update, &myc_update, &migration_delete] {
    557         sqlx::raw_sql(sqlx::AssertSqlSafe(sql.as_str()))
    558             .execute(&mut connection)
    559             .await
    560             .expect("restore exact governed trigger");
    561     }
    562     connection.close().await.expect("fixture connection close");
    563 
    564     let host = open_myc_state_read_write(&runtime, &metadata, applied_at, &build)
    565         .await
    566         .expect("schema-v3 upgrade");
    567     let admitted = host
    568         .repository()
    569         .admit_signer_request(&request(
    570             "request-after-v3",
    571             0x66,
    572             MycSignerRequestMethod::Ping,
    573             b"after-v3",
    574             0x66,
    575             300,
    576         ))
    577         .await
    578         .expect("request admission after migration");
    579     assert!(matches!(admitted, MycSignerRequestAdmission::Admitted(_)));
    580     host.close().await.expect("upgraded host close");
    581 }
    582 
    583 #[tokio::test]
    584 async fn persisted_operation_entropy_tampering_fails_closed_as_a_binding_error() {
    585     let directory = tempfile::tempdir().expect("temporary root");
    586     let runtime = runtime(directory.path());
    587     prepare_state_directory(&runtime);
    588     let metadata = metadata(&runtime);
    589     let (applied_at, build) = migration_evidence();
    590     initialize_myc_state(&runtime, &metadata, applied_at, &build)
    591         .await
    592         .expect("state initialization");
    593     let host = open_myc_state_read_write(&runtime, &metadata, applied_at, &build)
    594         .await
    595         .expect("writable host");
    596     let request = request(
    597         "request-tamper",
    598         0x77,
    599         MycSignerRequestMethod::Ping,
    600         b"tamper-check",
    601         0x77,
    602         400,
    603     );
    604     host.repository()
    605         .admit_signer_request(&request)
    606         .await
    607         .expect("initial request admission");
    608     host.close().await.expect("host close before tamper");
    609 
    610     let options = SqliteConnectOptions::new()
    611         .filename(runtime.artifacts().state_database())
    612         .create_if_missing(false)
    613         .disable_statement_logging();
    614     let mut connection = sqlx::SqliteConnection::connect_with(&options)
    615         .await
    616         .expect("tamper fixture connection");
    617     let request_guard = sqlx::query_scalar::<_, String>(
    618         "SELECT sql FROM sqlite_schema WHERE type = 'trigger' \
    619          AND name = 'nip46_requests_no_update'",
    620     )
    621     .fetch_one(&mut connection)
    622     .await
    623     .expect("request guard");
    624     sqlx::query("DROP TRIGGER nip46_requests_no_update")
    625         .execute(&mut connection)
    626         .await
    627         .expect("drop request guard for corruption fixture");
    628     sqlx::query("UPDATE nip46_requests SET operation_nonce = zeroblob(32)")
    629         .execute(&mut connection)
    630         .await
    631         .expect("corrupt persisted operation nonce");
    632     sqlx::raw_sql(sqlx::AssertSqlSafe(request_guard.as_str()))
    633         .execute(&mut connection)
    634         .await
    635         .expect("restore request guard");
    636     connection.close().await.expect("fixture connection close");
    637 
    638     let host = open_myc_state_read_write(&runtime, &metadata, applied_at, &build)
    639         .await
    640         .expect("reopen structurally valid state");
    641     let error = host
    642         .repository()
    643         .admit_signer_request(&request)
    644         .await
    645         .expect_err("tampered operation binding");
    646     assert_eq!(error.kind(), myc::MycStateRepositoryErrorKind::Binding);
    647     host.close().await.expect("host close after rejection");
    648 }
    649 
    650 #[test]
    651 fn request_store_is_sealed_transactional_bounded_and_independent_of_external_effects() {
    652     assert!(REQUEST_SOURCE.contains("ServiceSqliteTransaction<'_>"));
    653     assert!(REQUEST_SOURCE.contains("require_expected_metadata(transaction, &expected)"));
    654     assert!(REQUEST_SOURCE.contains("CASE WHEN typeof(r.operation_id) = 'blob'"));
    655     assert!(REQUEST_SOURCE.contains("END AS logical_operation_id"));
    656     assert!(REQUEST_SOURCE.contains("LIMIT 2"));
    657     assert!(REQUEST_SOURCE.contains("replay_count < 9223372036854775807"));
    658     assert!(REQUEST_SOURCE.contains("conflict_count < 9223372036854775807"));
    659     assert!(CATALOG_SOURCE.contains("CREATE TABLE nip46_requests"));
    660     assert!(CATALOG_SOURCE.contains("CREATE TABLE nip46_request_dedup"));
    661     assert!(CATALOG_SOURCE.contains("nip46_request_dedup_guard_update"));
    662     for forbidden in [
    663         "SqlitePool",
    664         "SqliteConnection",
    665         "BEGIN ",
    666         "COMMIT",
    667         "ROLLBACK",
    668         "std::fs",
    669         "std::env",
    670         "SystemTime",
    671         "Instant::now",
    672         "tokio::spawn",
    673         "spawn_blocking",
    674         "rand::",
    675         "getrandom",
    676         "SystemEntropy",
    677         "reqwest",
    678         "RelayPool",
    679         "provider.await",
    680     ] {
    681         assert!(
    682             !REQUEST_SOURCE.contains(forbidden),
    683             "found forbidden request-store authority `{forbidden}`"
    684         );
    685     }
    686 }