myc

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

operability_cli.rs (14159B)


      1 use std::fs;
      2 use std::path::Path;
      3 use std::process::Command;
      4 
      5 use myc::{
      6     MYC_SIGNER_STATUS_CONTRACT_VERSION, MycActiveIdentity, MycDeliveryOutboxKind,
      7     MycDeliveryOutboxRecord, MycOperationAuditKind, MycOperationAuditOutcome,
      8     MycOperationAuditRecord, MycRuntime,
      9 };
     10 use radroots_identity::RadrootsIdentity;
     11 use radroots_nostr::prelude::{RadrootsNostrEventBuilder, RadrootsNostrKind};
     12 use serde_json::{Value, json};
     13 
     14 fn write_test_identity(path: &Path, secret_key: &str) {
     15     let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity from secret");
     16     myc::identity_files::store_encrypted_identity(path, &identity).expect("write identity");
     17 }
     18 
     19 fn write_env_file(temp: &tempfile::TempDir) -> std::path::PathBuf {
     20     let state_dir = temp.path().join("state");
     21     let signer_path = temp.path().join("signer.json");
     22     let user_path = temp.path().join("user.json");
     23     let env_path = temp.path().join("myc.env");
     24 
     25     write_test_identity(
     26         signer_path.as_path(),
     27         "1111111111111111111111111111111111111111111111111111111111111111",
     28     );
     29     write_test_identity(
     30         user_path.as_path(),
     31         "2222222222222222222222222222222222222222222222222222222222222222",
     32     );
     33 
     34     std::fs::write(
     35         &env_path,
     36         format!(
     37             "MYC_SERVICE_INSTANCE_NAME=myc-test\n\
     38 MYC_LOGGING_FILTER=info,myc=info\n\
     39 MYC_LOGGING_STDOUT=false\n\
     40 MYC_PATHS_STATE_DIR={}\n\
     41 MYC_IDENTITY_SIGNER_PATH={}\n\
     42 MYC_IDENTITY_USER_PATH={}\n\
     43 MYC_DISCOVERY_ENABLED=false\n\
     44 MYC_TRANSPORT_ENABLED=false\n\
     45 MYC_TRANSPORT_CONNECT_TIMEOUT_SECS=1\n",
     46             state_dir.display(),
     47             signer_path.display(),
     48             user_path.display(),
     49         ),
     50     )
     51     .expect("write env");
     52 
     53     env_path
     54 }
     55 
     56 fn signed_event(identity: &MycActiveIdentity) -> nostr::Event {
     57     identity
     58         .sign_event_builder(
     59             RadrootsNostrEventBuilder::new(RadrootsNostrKind::Custom(24133), "operability"),
     60             "operability test event",
     61         )
     62         .expect("sign event")
     63 }
     64 
     65 #[test]
     66 fn status_signer_command_emits_local_contract_json() {
     67     let temp = tempfile::tempdir().expect("tempdir");
     68     let env_path = write_env_file(&temp);
     69 
     70     let output = Command::new(env!("CARGO_BIN_EXE_myc"))
     71         .arg("--env-file")
     72         .arg(&env_path)
     73         .arg("status")
     74         .arg("--view")
     75         .arg("signer")
     76         .output()
     77         .expect("run myc signer status");
     78 
     79     assert!(output.status.success());
     80     let value: Value = serde_json::from_slice(&output.stdout).expect("signer status json");
     81     assert_eq!(
     82         value["status_contract_version"],
     83         MYC_SIGNER_STATUS_CONTRACT_VERSION
     84     );
     85     assert_eq!(value["status"], "healthy");
     86     assert_eq!(value["ready"], true);
     87     assert_eq!(
     88         value["runtime_contract"]["active_profile"],
     89         "interactive_user"
     90     );
     91     assert_eq!(value["custody"]["signer"]["resolved"], true);
     92     assert_eq!(value["custody"]["user"]["resolved"], true);
     93     assert_eq!(
     94         value["signer_backend"]["local_signer"]["availability"],
     95         "SecretBacked"
     96     );
     97     assert_eq!(value["signer_backend"]["remote_session_count"], 0);
     98     assert!(value.get("transport").is_none());
     99     assert!(value.get("discovery").is_none());
    100     assert!(value.get("persistence").is_none());
    101     assert!(value.get("delivery_outbox").is_none());
    102 }
    103 
    104 #[test]
    105 fn status_ignores_retired_process_env_config_names() {
    106     let temp = tempfile::tempdir().expect("tempdir");
    107     let env_path = write_env_file(&temp);
    108 
    109     let output = Command::new(env!("CARGO_BIN_EXE_myc"))
    110         .env(
    111             "MYC_PATHS_SIGNER_IDENTITY_PATH",
    112             temp.path().join("missing-signer.json"),
    113         )
    114         .env(
    115             "MYC_PATHS_USER_IDENTITY_PATH",
    116             temp.path().join("missing-user.json"),
    117         )
    118         .env("MYC_DISCOVERY_PUBLIC_RELAYS", "not-a-relay")
    119         .env("MYC_TRANSPORT_RELAYS", "not-a-relay")
    120         .env("MYC_TRANSPORT_PUBLISH_INITIAL_BACKOFF_MILLIS", "0")
    121         .arg("--env-file")
    122         .arg(&env_path)
    123         .arg("status")
    124         .arg("--view")
    125         .arg("signer")
    126         .output()
    127         .expect("run myc signer status");
    128 
    129     assert!(output.status.success());
    130     let value: Value = serde_json::from_slice(&output.stdout).expect("signer status json");
    131     assert_eq!(value["ready"], true);
    132     assert_eq!(value["custody"]["signer"]["resolved"], true);
    133     assert_eq!(value["custody"]["user"]["resolved"], true);
    134 }
    135 
    136 #[test]
    137 fn status_summary_command_emits_machine_readable_json() {
    138     let temp = tempfile::tempdir().expect("tempdir");
    139     let env_path = write_env_file(&temp);
    140 
    141     let output = Command::new(env!("CARGO_BIN_EXE_myc"))
    142         .arg("--env-file")
    143         .arg(&env_path)
    144         .arg("status")
    145         .arg("--view")
    146         .arg("summary")
    147         .output()
    148         .expect("run myc status");
    149 
    150     assert!(output.status.success());
    151     let value: Value = serde_json::from_slice(&output.stdout).expect("status json");
    152     assert_eq!(value["status"], "unready");
    153     assert_eq!(value["ready"], false);
    154     assert_eq!(
    155         value["runtime_contract"]["active_profile"],
    156         "interactive_user"
    157     );
    158     assert_eq!(
    159         value["runtime_contract"]["path_overrides"]["canonical_root_selection"],
    160         "profile_root_env_or_repo_wrapper"
    161     );
    162     assert_eq!(
    163         value["runtime_contract"]["path_overrides"]["canonical_subordinate_path_override"],
    164         "config_artifact"
    165     );
    166     assert_eq!(
    167         value["runtime_contract"]["path_overrides"]["leaf_path_env_posture"],
    168         "compatibility_break_glass"
    169     );
    170     assert_eq!(
    171         value["runtime_contract"]["path_overrides"]["compatibility_leaf_path_keys"],
    172         json!([
    173             "MYC_LOGGING_OUTPUT_DIR",
    174             "MYC_PATHS_STATE_DIR",
    175             "MYC_IDENTITY_SIGNER_PATH",
    176             "MYC_IDENTITY_USER_PATH",
    177             "MYC_IDENTITY_DISCOVERY_APP_PATH",
    178             "MYC_DISCOVERY_NIP05_OUTPUT_PATH"
    179         ])
    180     );
    181     assert_eq!(
    182         value["runtime_contract"]["allowed_profiles"],
    183         json!(["interactive_user", "service_host", "repo_local"])
    184     );
    185     assert_eq!(
    186         value["runtime_contract"]["default_shared_secret_backend"],
    187         "encrypted_file"
    188     );
    189     assert_eq!(
    190         value["runtime_contract"]["allowed_shared_secret_backends"],
    191         json!([
    192             "encrypted_file",
    193             "host_vault",
    194             "external_command",
    195             "plaintext_file"
    196         ])
    197     );
    198     assert_eq!(
    199         value["runtime_contract"]["runtime_specific_custody_modes"],
    200         json!(["managed_account"])
    201     );
    202     assert_eq!(value["runtime_contract"]["host_vault_policy"], "desktop");
    203     assert_eq!(value["custody"]["signer"]["backend"], "encrypted_file");
    204     assert_eq!(value["custody"]["signer"]["resolved"], true);
    205     assert_eq!(value["persistence"]["signer_state"]["backend"], "json_file");
    206     assert_eq!(
    207         value["persistence"]["runtime_audit"]["backend"],
    208         "jsonl_file"
    209     );
    210     assert_eq!(value["delivery_outbox"]["status"], "healthy");
    211     assert_eq!(value["delivery_outbox"]["ready"], true);
    212     assert_eq!(value["delivery_outbox"]["total_job_count"], 0);
    213     assert_eq!(value["transport"]["enabled"], false);
    214 }
    215 
    216 #[test]
    217 fn metrics_command_emits_json_and_prometheus_formats() {
    218     let temp = tempfile::tempdir().expect("tempdir");
    219     let env_path = write_env_file(&temp);
    220     let config = myc::MycConfig::load_from_env_path(&env_path).expect("load config");
    221     let runtime = MycRuntime::bootstrap(config).expect("runtime");
    222     runtime.record_operation_audit(&MycOperationAuditRecord::new(
    223         MycOperationAuditKind::AuthReplayRestore,
    224         MycOperationAuditOutcome::Restored,
    225         None,
    226         None,
    227         1,
    228         0,
    229         "restored pending request after failed replay publish",
    230     ));
    231     runtime.record_operation_audit(&MycOperationAuditRecord::new(
    232         MycOperationAuditKind::DeliveryRecovery,
    233         MycOperationAuditOutcome::Succeeded,
    234         None,
    235         None,
    236         1,
    237         1,
    238         "recovered 1/1 delivery outbox job(s); republished 1",
    239     ));
    240     let outbox_record = MycDeliveryOutboxRecord::new(
    241         MycDeliveryOutboxKind::DiscoveryHandlerPublish,
    242         signed_event(runtime.signer_identity()),
    243         vec!["wss://relay.example.com".parse().expect("relay url")],
    244     )
    245     .expect("outbox record");
    246     runtime
    247         .delivery_outbox_store()
    248         .enqueue(&outbox_record)
    249         .expect("enqueue outbox record");
    250 
    251     let json_output = Command::new(env!("CARGO_BIN_EXE_myc"))
    252         .arg("--env-file")
    253         .arg(&env_path)
    254         .arg("metrics")
    255         .arg("--format")
    256         .arg("json")
    257         .output()
    258         .expect("run myc metrics json");
    259     assert!(json_output.status.success());
    260     let json_value: Value = serde_json::from_slice(&json_output.stdout).expect("metrics json");
    261     assert_eq!(json_value["runtime_replay_restore_count"], 1);
    262     assert_eq!(json_value["delivery_recovery_success_count"], 1);
    263     assert_eq!(json_value["delivery_outbox_total"], 1);
    264     assert_eq!(json_value["delivery_outbox_queued_count"], 1);
    265 
    266     let prometheus_output = Command::new(env!("CARGO_BIN_EXE_myc"))
    267         .arg("--env-file")
    268         .arg(&env_path)
    269         .arg("metrics")
    270         .arg("--format")
    271         .arg("prometheus")
    272         .output()
    273         .expect("run myc metrics prometheus");
    274     assert!(prometheus_output.status.success());
    275     let rendered = String::from_utf8(prometheus_output.stdout).expect("utf8 metrics");
    276     assert!(rendered.contains("myc_runtime_replay_restore_total 1"));
    277     assert!(rendered.contains("myc_delivery_recovery_success_total 1"));
    278     assert!(rendered.contains("myc_delivery_outbox_total 1"));
    279     assert!(rendered.contains("myc_signer_request_total 0"));
    280 }
    281 
    282 #[test]
    283 fn custody_status_command_reports_role_backend_details() {
    284     let temp = tempfile::tempdir().expect("tempdir");
    285     let env_path = write_env_file(&temp);
    286 
    287     let output = Command::new(env!("CARGO_BIN_EXE_myc"))
    288         .arg("--env-file")
    289         .arg(&env_path)
    290         .arg("custody")
    291         .arg("status")
    292         .arg("--role")
    293         .arg("signer")
    294         .output()
    295         .expect("run myc custody status");
    296 
    297     assert!(output.status.success());
    298     let value: Value = serde_json::from_slice(&output.stdout).expect("custody status json");
    299     assert_eq!(value["backend"], "encrypted_file");
    300     assert_eq!(value["resolved"], true);
    301     assert_eq!(value["default_shared_secret_backend"], "encrypted_file");
    302     assert_eq!(
    303         value["allowed_shared_secret_backends"],
    304         json!([
    305             "encrypted_file",
    306             "host_vault",
    307             "external_command",
    308             "plaintext_file"
    309         ])
    310     );
    311     assert_eq!(
    312         value["runtime_specific_custody_modes"],
    313         json!(["managed_account"])
    314     );
    315     assert_eq!(value["host_vault_policy"], "desktop");
    316     assert_eq!(
    317         value["identity_id"],
    318         "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
    319     );
    320 }
    321 
    322 #[test]
    323 fn custody_export_import_and_rotate_nip49_for_encrypted_file_backend() {
    324     let temp = tempfile::tempdir().expect("tempdir");
    325     let env_path = write_env_file(&temp);
    326     let signer_path = temp.path().join("signer.json");
    327     let export_path = temp.path().join("signer.ncryptsec");
    328     let key_path = myc::identity_files::encrypted_identity_wrapping_key_path(&signer_path);
    329 
    330     let export_output = Command::new(env!("CARGO_BIN_EXE_myc"))
    331         .env("MYC_TEST_PASSWORD", "correct horse battery staple")
    332         .arg("--env-file")
    333         .arg(&env_path)
    334         .arg("custody")
    335         .arg("export-nip49")
    336         .arg("--role")
    337         .arg("signer")
    338         .arg("--out")
    339         .arg(&export_path)
    340         .arg("--password-env")
    341         .arg("MYC_TEST_PASSWORD")
    342         .output()
    343         .expect("run myc custody export-nip49");
    344 
    345     assert!(export_output.status.success());
    346     let export_value: Value =
    347         serde_json::from_slice(&export_output.stdout).expect("export-nip49 json");
    348     assert_eq!(export_value["format"], "nip49");
    349     assert_eq!(export_value["out"], export_path.display().to_string());
    350     let exported = fs::read_to_string(&export_path).expect("read exported ncryptsec");
    351     assert!(exported.starts_with("ncryptsec1"));
    352 
    353     fs::remove_file(&signer_path).expect("remove signer identity");
    354     fs::remove_file(&key_path).expect("remove signer wrapping key");
    355 
    356     let import_output = Command::new(env!("CARGO_BIN_EXE_myc"))
    357         .env("MYC_TEST_PASSWORD", "correct horse battery staple")
    358         .arg("--env-file")
    359         .arg(&env_path)
    360         .arg("custody")
    361         .arg("import-nip49")
    362         .arg("--role")
    363         .arg("signer")
    364         .arg("--path")
    365         .arg(&export_path)
    366         .arg("--password-env")
    367         .arg("MYC_TEST_PASSWORD")
    368         .output()
    369         .expect("run myc custody import-nip49");
    370 
    371     assert!(import_output.status.success());
    372     let import_value: Value =
    373         serde_json::from_slice(&import_output.stdout).expect("import-nip49 json");
    374     assert_eq!(import_value["format"], "nip49");
    375     assert_eq!(import_value["status"]["resolved"], true);
    376     let restored = myc::identity_files::load_encrypted_identity(&signer_path)
    377         .expect("load restored encrypted identity");
    378     assert_eq!(
    379         restored.id().to_string(),
    380         "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
    381     );
    382 
    383     let key_before_rotation = fs::read(&key_path).expect("read key before rotation");
    384     let rotate_output = Command::new(env!("CARGO_BIN_EXE_myc"))
    385         .arg("--env-file")
    386         .arg(&env_path)
    387         .arg("custody")
    388         .arg("rotate")
    389         .arg("--role")
    390         .arg("signer")
    391         .output()
    392         .expect("run myc custody rotate");
    393 
    394     assert!(rotate_output.status.success());
    395     let rotate_value: Value = serde_json::from_slice(&rotate_output.stdout).expect("rotate json");
    396     assert_eq!(rotate_value["action"], "rotate");
    397     assert_eq!(rotate_value["status"]["resolved"], true);
    398     let key_after_rotation = fs::read(&key_path).expect("read key after rotation");
    399     assert_ne!(key_before_rotation, key_after_rotation);
    400 }