myc

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

services_hardening_process.rs (9564B)


      1 #![forbid(unsafe_code)]
      2 #![cfg(any(target_os = "linux", target_os = "macos"))]
      3 
      4 use std::io::Write as _;
      5 use std::os::unix::fs::PermissionsExt as _;
      6 use std::path::PathBuf;
      7 use std::process::{Command, Output, Stdio};
      8 
      9 use sha2::{Digest, Sha256};
     10 
     11 const CONFIG_EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml");
     12 
     13 struct ProcessFixture {
     14     root: tempfile::TempDir,
     15     config: PathBuf,
     16 }
     17 
     18 impl ProcessFixture {
     19     fn new() -> Self {
     20         let root = tempfile::tempdir().expect("repo-local root");
     21         std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o700))
     22             .expect("secure repo-local root");
     23         let config = root.path().join("myc.toml");
     24         Self { root, config }
     25     }
     26 
     27     fn command(&self, command: &[&str]) -> Command {
     28         let mut process = Command::new(env!("CARGO_BIN_EXE_myc"));
     29         process
     30             .args(["--profile", "repo-local", "--instance", "primary"])
     31             .arg("--repo-local-root")
     32             .arg(self.root.path())
     33             .arg("--config")
     34             .arg(&self.config)
     35             .args(command);
     36         process
     37     }
     38 
     39     fn run(&self, command: &[&str]) -> Output {
     40         self.command(command).output().expect("Myc process")
     41     }
     42 
     43     fn run_with_stdin(&self, command: &[&str], bytes: &[u8]) -> Output {
     44         let mut child = self
     45             .command(command)
     46             .stdin(Stdio::piped())
     47             .stdout(Stdio::piped())
     48             .stderr(Stdio::piped())
     49             .spawn()
     50             .expect("Myc process");
     51         child
     52             .stdin
     53             .take()
     54             .expect("stdin")
     55             .write_all(bytes)
     56             .expect("write stdin");
     57         child.wait_with_output().expect("process result")
     58     }
     59 
     60     fn state_directory(&self) -> PathBuf {
     61         self.root.path().join("data/services/myc/primary")
     62     }
     63 }
     64 
     65 fn repo_local_config() -> String {
     66     CONFIG_EXAMPLE
     67         .replace("wss://relay-primary.example.test/", "ws://127.0.0.1:9/")
     68         .replace("wss://relay-secondary.example.test/", "ws://127.0.0.1:10/")
     69         .replace("connect_deadline_ms = 10000", "connect_deadline_ms = 100")
     70 }
     71 
     72 fn stderr_code(output: &Output) -> String {
     73     let value: serde_json::Value = serde_json::from_slice(&output.stderr).expect("diagnostic JSON");
     74     value["code"].as_str().expect("diagnostic code").to_owned()
     75 }
     76 
     77 fn assert_success(output: &Output) {
     78     assert_eq!(output.status.code(), Some(0), "stderr: {:?}", output.stderr);
     79     assert_eq!(stderr_code(output), "success");
     80 }
     81 
     82 #[test]
     83 fn binary_executes_config_state_backup_restore_and_doctor_boundaries() {
     84     let fixture = ProcessFixture::new();
     85     let config = repo_local_config();
     86 
     87     let initialized = fixture.run_with_stdin(&["config", "init"], config.as_bytes());
     88     assert_success(&initialized);
     89     assert_eq!(initialized.stdout, b"config_initialized\n");
     90     assert_eq!(
     91         std::fs::metadata(&fixture.config)
     92             .expect("config metadata")
     93             .permissions()
     94             .mode()
     95             & 0o777,
     96         0o600
     97     );
     98     std::fs::create_dir_all(fixture.state_directory()).expect("provisioned state directory");
     99     std::fs::set_permissions(
    100         fixture.state_directory(),
    101         std::fs::Permissions::from_mode(0o700),
    102     )
    103     .expect("secure state directory");
    104 
    105     for (command, result) in [
    106         (&["config", "validate"][..], b"config_valid\n".as_slice()),
    107         (&["state", "init"][..], b"state_initialized\n".as_slice()),
    108         (&["state", "verify"][..], b"state_verified\n".as_slice()),
    109         (&["state", "migrate"][..], b"state_migrated\n".as_slice()),
    110     ] {
    111         let output = fixture.run(command);
    112         assert_eq!(
    113             output.status.code(),
    114             Some(0),
    115             "command {command:?}, stderr: {:?}",
    116             output.stderr
    117         );
    118         assert_success(&output);
    119         assert_eq!(output.stdout, result);
    120     }
    121 
    122     let shown = fixture.run(&["--output", "json", "config", "show"]);
    123     assert_success(&shown);
    124     let shown_value: serde_json::Value =
    125         serde_json::from_slice(&shown.stdout).expect("effective configuration JSON");
    126     assert_eq!(shown_value["schema"], "radroots.myc.effective-config");
    127 
    128     let schema = fixture.run(&["config", "schema"]);
    129     assert_success(&schema);
    130     let schema_value: serde_json::Value =
    131         serde_json::from_slice(&schema.stdout).expect("configuration schema JSON");
    132     assert_eq!(
    133         schema_value["$id"],
    134         "https://github.com/radrootslabs/myc/contracts/services_hardening/config.v1.schema.json"
    135     );
    136 
    137     let status = fixture.run(&["state", "status"]);
    138     assert_success(&status);
    139     let status_value: serde_json::Value =
    140         serde_json::from_slice(&status.stdout).expect("state status JSON");
    141     assert_eq!(status_value["generation"], 1);
    142     assert_eq!(status_value["schema_version"], 12);
    143     assert_eq!(status_value["integrity"], "verified");
    144 
    145     let service_status = fixture.run(&["status"]);
    146     assert_success(&service_status);
    147     let service_status_value: serde_json::Value =
    148         serde_json::from_slice(&service_status.stdout).expect("service status JSON");
    149     assert_eq!(
    150         service_status_value["myc"]["connection_counts"],
    151         serde_json::json!({
    152             "active": 0,
    153             "denied": 0,
    154             "expired": 0,
    155             "pending": 0,
    156         })
    157     );
    158     assert_eq!(
    159         service_status_value["myc"]["outbox"],
    160         serde_json::json!({"pending": 0, "unknown": 0})
    161     );
    162 
    163     let bundle = fixture.root.path().join("backup");
    164     let backup = fixture
    165         .command(&["state", "backup", "--operation-id", "process-backup-01"])
    166         .arg("--target")
    167         .arg(&bundle)
    168         .args(["--expected-generation", "1", "--confirm"])
    169         .output()
    170         .expect("backup process");
    171     assert_success(&backup);
    172     assert!(!backup.stdout.ends_with(b"\n"));
    173     let manifest_bytes = backup.stdout.as_slice();
    174     let manifest: serde_json::Value =
    175         serde_json::from_slice(manifest_bytes).expect("backup manifest");
    176     assert_eq!(manifest["service"], "myc");
    177     assert_eq!(manifest["instance"], "primary");
    178     let digest = hex::encode(Sha256::digest(manifest_bytes));
    179     let manifest_path = fixture.root.path().join("manifest.json");
    180     std::fs::write(&manifest_path, manifest_bytes).expect("manifest file");
    181     std::fs::set_permissions(&manifest_path, std::fs::Permissions::from_mode(0o600))
    182         .expect("secure manifest");
    183 
    184     let live_database = fixture.state_directory().join("state.sqlite");
    185     let mut corrupted = std::fs::OpenOptions::new()
    186         .write(true)
    187         .truncate(true)
    188         .open(&live_database)
    189         .expect("open live database for corruption fixture");
    190     corrupted
    191         .write_all(b"corrupt-live-database")
    192         .and_then(|()| corrupted.sync_all())
    193         .expect("persist corrupt live database fixture");
    194     drop(corrupted);
    195 
    196     let restored = fixture
    197         .command(&["state", "restore"])
    198         .arg("--manifest")
    199         .arg(&manifest_path)
    200         .arg("--manifest-sha256")
    201         .arg(&digest)
    202         .arg("--bundle")
    203         .arg(&bundle)
    204         .args(["--maximum-state-bytes", "16777216", "--confirm"])
    205         .output()
    206         .expect("restore process");
    207     assert_success(&restored);
    208     assert_eq!(restored.stdout, b"state_restore_finalized\n");
    209 
    210     let verified_after_restore = fixture.run(&["state", "verify"]);
    211     assert_success(&verified_after_restore);
    212     for artifact in [
    213         "state.restore-marker.v1",
    214         "state.restore-marker.v1.next",
    215         "state.restore-staged.sqlite",
    216         "state.restore-backup.sqlite",
    217     ] {
    218         assert!(!fixture.state_directory().join(artifact).exists());
    219     }
    220 
    221     let doctor = fixture.run(&["doctor"]);
    222     assert_eq!(doctor.status.code(), Some(6));
    223     assert_eq!(stderr_code(&doctor), "doctor_required_check_failed");
    224     let doctor_value: serde_json::Value =
    225         serde_json::from_slice(&doctor.stdout).expect("doctor report JSON");
    226     assert_eq!(doctor_value["checks"].as_array().expect("checks").len(), 13);
    227     assert_eq!(doctor_value["checks"][12]["status"], "skipped");
    228 
    229     let run = fixture.run(&["run"]);
    230     assert_eq!(run.status.code(), Some(3));
    231     assert!(run.stdout.is_empty());
    232     assert_eq!(stderr_code(&run), "service_or_dependency_unavailable");
    233 }
    234 
    235 #[test]
    236 fn binary_rejects_insecure_or_oversized_selected_documents_without_disclosure() {
    237     let fixture = ProcessFixture::new();
    238     std::fs::write(&fixture.config, repo_local_config()).expect("config");
    239     std::fs::set_permissions(&fixture.config, std::fs::Permissions::from_mode(0o620))
    240         .expect("insecure config mode");
    241 
    242     let insecure = fixture.run(&["config", "validate"]);
    243     assert_eq!(insecure.status.code(), Some(2));
    244     assert!(insecure.stdout.is_empty());
    245     assert_eq!(stderr_code(&insecure), "input_or_configuration");
    246     let rendered = String::from_utf8(insecure.stderr).expect("diagnostic text");
    247     assert!(!rendered.contains(fixture.config.to_str().expect("UTF-8 path")));
    248 
    249     std::fs::set_permissions(&fixture.config, std::fs::Permissions::from_mode(0o600))
    250         .expect("secure config mode");
    251     std::fs::write(
    252         &fixture.config,
    253         vec![b'x'; myc::MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES + 1],
    254     )
    255     .expect("oversized config");
    256     let oversized = fixture.run(&["config", "validate"]);
    257     assert_eq!(oversized.status.code(), Some(2));
    258     assert!(oversized.stdout.is_empty());
    259     assert_eq!(stderr_code(&oversized), "input_or_configuration");
    260 }