myc

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

services_hardening_contracts.rs (19804B)


      1 #![forbid(unsafe_code)]
      2 
      3 use serde_json::Value;
      4 use sha2::{Digest, Sha256};
      5 use std::collections::BTreeSet;
      6 
      7 const OPERATOR_CONTRACT: &str =
      8     include_str!("../contracts/services_hardening/operator_contract.v1.json");
      9 const CONSUMER_ROOT: &str = include_str!("../.radroots-consumer-root");
     10 
     11 fn contract() -> Value {
     12     serde_json::from_str(OPERATOR_CONTRACT).expect("operator contract must be valid JSON")
     13 }
     14 
     15 fn decision_sections_digest(value: &Value) -> String {
     16     let sections = serde_json::json!({
     17         "identity_contract": value["admin"]["identity_contract"],
     18         "model_wire_contract": value["admin"]["model_wire_contract"],
     19         "models": value["admin"]["models"],
     20         "mutation_contract": value["admin"]["mutation_contract"],
     21         "pagination": value["admin"]["pagination"],
     22         "path_parameters": value["admin"]["path_parameters"],
     23         "types": value["admin"]["types"]
     24     });
     25     hex::encode(Sha256::digest(
     26         serde_json::to_vec(&sections).expect("serialize decision sections"),
     27     ))
     28 }
     29 
     30 fn permission_vector_is_valid(value: &str) -> bool {
     31     if matches!(
     32         value,
     33         "nip04_decrypt" | "nip04_encrypt" | "nip44_decrypt" | "nip44_encrypt" | "switch_relays"
     34     ) {
     35         return true;
     36     }
     37     let Some(kind) = value.strip_prefix("sign_event:kind:") else {
     38         return false;
     39     };
     40     if kind.is_empty() || (kind.len() > 1 && kind.starts_with('0')) {
     41         return false;
     42     }
     43     kind.bytes().all(|byte| byte.is_ascii_digit()) && kind.parse::<u32>().is_ok()
     44 }
     45 
     46 #[test]
     47 fn source_lock_identity_and_shared_host_reference_are_exact() {
     48     assert_eq!(CONSUMER_ROOT, "myc\n");
     49     let value = contract();
     50     assert_eq!(value["schema"], "radroots.myc.operator-contract.v1");
     51     assert_eq!(value["contract_version"], 1);
     52     assert_eq!(value["decision_state"], "implemented");
     53     assert_eq!(value["service"], "myc");
     54     assert_eq!(
     55         value["shared_host_contract"],
     56         serde_json::json!({
     57             "repository": "https://github.com/radrootslabs/lib",
     58             "path": "contracts/architecture/decisions/services_hardening_host.v1.json",
     59             "schema": "radroots.services-hardening.host-decisions.v1",
     60             "contract_version": 1
     61         })
     62     );
     63 }
     64 
     65 #[test]
     66 fn admin_inventory_is_closed_unique_and_model_complete() {
     67     let value = contract();
     68     assert_eq!(
     69         value["admin"]["transport"],
     70         "http_1_1_over_unix_domain_socket"
     71     );
     72     assert_eq!(value["admin"]["base_path"], "/v1");
     73     assert_eq!(value["admin"]["route_inventory_closed"], true);
     74 
     75     let routes = value["admin"]["routes"].as_array().expect("routes");
     76     let exact_routes = routes
     77         .iter()
     78         .map(|route| {
     79             format!(
     80                 "{}|{}|{}|{}|{}|{}",
     81                 route["method"].as_str().unwrap(),
     82                 route["path"].as_str().unwrap(),
     83                 route["operation_id"].as_str().unwrap(),
     84                 route["request_model"].as_str().unwrap(),
     85                 route["response_model"].as_str().unwrap(),
     86                 route["mutation"].as_bool().unwrap()
     87             )
     88         })
     89         .collect::<Vec<_>>();
     90     assert_eq!(
     91         exact_routes,
     92         [
     93             "GET|/v1/status|radroots.myc.status.get.v1|empty|service_status_v1|false",
     94             "GET|/v1/config/effective|radroots.myc.config.effective.get.v1|empty|effective_config_v1|false",
     95             "GET|/v1/identity/status|radroots.myc.identity.status.get.v1|identity_status_query_v1|identity_status_v1|false",
     96             "GET|/v1/identity/public|radroots.myc.identity.public.get.v1|identity_public_query_v1|identity_public_v1|false",
     97             "GET|/v1/state/status|radroots.myc.state.status.get.v1|empty|state_status_v1|false",
     98             "POST|/v1/state/backup|radroots.myc.state.backup.create.v1|state_backup_request_v1|state_backup_receipt_v1|true",
     99             "GET|/v1/metrics/snapshot|radroots.myc.metrics.snapshot.get.v1|empty|metrics_snapshot_v1|false",
    100             "GET|/v1/connections|radroots.myc.connections.list.v1|connections_query_v1|connections_page_v1|false",
    101             "POST|/v1/connections/{connection_id}/approve|radroots.myc.connection.approve.v1|connection_approve_request_v1|connection_mutation_receipt_v1|true",
    102             "POST|/v1/connections/{connection_id}/reject|radroots.myc.connection.reject.v1|connection_reject_request_v1|connection_mutation_receipt_v1|true",
    103             "POST|/v1/connections/{connection_id}/revoke|radroots.myc.connection.revoke.v1|connection_revoke_request_v1|connection_mutation_receipt_v1|true",
    104             "POST|/v1/authorization/challenges/require|radroots.myc.authorization.challenge.require.v1|challenge_require_request_v1|challenge_v1|true",
    105             "POST|/v1/authorization/challenges/{challenge_id}/authorize|radroots.myc.authorization.challenge.authorize.v1|challenge_authorize_request_v1|challenge_authorization_receipt_v1|true",
    106             "GET|/v1/audit/events|radroots.myc.audit.events.list.v1|audit_events_query_v1|audit_events_page_v1|false",
    107             "GET|/v1/audit/summary|radroots.myc.audit.summary.get.v1|audit_summary_query_v1|audit_summary_v1|false",
    108             "GET|/v1/discovery/desired|radroots.myc.discovery.desired.get.v1|empty|discovery_desired_v1|false",
    109             "POST|/v1/discovery/render|radroots.myc.discovery.render.v1|discovery_render_request_v1|discovery_render_receipt_v1|true",
    110             "POST|/v1/discovery/refresh|radroots.myc.discovery.refresh.v1|discovery_refresh_request_v1|discovery_refresh_receipt_v1|true",
    111             "POST|/v1/discovery/publish|radroots.myc.discovery.publish.v1|discovery_publish_request_v1|discovery_publish_receipt_v1|true"
    112         ]
    113     );
    114     let route_keys = routes
    115         .iter()
    116         .map(|route| format!("{} {}", route["method"], route["path"]))
    117         .collect::<BTreeSet<_>>();
    118     let operation_ids = routes
    119         .iter()
    120         .map(|route| route["operation_id"].as_str().expect("operation ID"))
    121         .collect::<BTreeSet<_>>();
    122     assert_eq!(route_keys.len(), routes.len());
    123     assert_eq!(operation_ids.len(), routes.len());
    124     assert!(
    125         operation_ids
    126             .iter()
    127             .all(|id| id.starts_with("radroots.myc.") && id.ends_with(".v1"))
    128     );
    129 
    130     let models = value["admin"]["models"].as_object().expect("models");
    131     let types = value["admin"]["types"].as_object().expect("types");
    132     for route in routes {
    133         for key in ["request_model", "response_model"] {
    134             let model = route[key].as_str().expect("model reference");
    135             assert!(models.contains_key(model), "missing model {model}");
    136         }
    137         assert_eq!(route["mutation"], route["method"] == "POST");
    138     }
    139     for (model_name, model) in models {
    140         assert_eq!(
    141             model
    142                 .as_object()
    143                 .unwrap()
    144                 .keys()
    145                 .map(String::as_str)
    146                 .collect::<Vec<_>>(),
    147             ["fields"],
    148             "model {model_name} must be a closed field inventory"
    149         );
    150         for (field_name, field) in model["fields"].as_object().unwrap() {
    151             assert_eq!(
    152                 field
    153                     .as_object()
    154                     .unwrap()
    155                     .keys()
    156                     .map(String::as_str)
    157                     .collect::<Vec<_>>(),
    158                 ["presence", "type"],
    159                 "field {model_name}.{field_name} must bind only type and presence"
    160             );
    161             assert!(
    162                 matches!(field["presence"].as_str(), Some("required" | "optional")),
    163                 "invalid presence for {model_name}.{field_name}"
    164             );
    165             let type_name = field["type"].as_str().unwrap();
    166             assert!(types.contains_key(type_name), "unknown type {type_name}");
    167         }
    168     }
    169     for (type_name, descriptor) in types {
    170         let referenced = match descriptor["kind"].as_str().unwrap() {
    171             "array" | "canonical_delimited_set" => {
    172                 vec![descriptor["items"].as_str().unwrap()]
    173             }
    174             "map" => vec![
    175                 descriptor["key"].as_str().unwrap(),
    176                 descriptor["value"].as_str().unwrap(),
    177             ],
    178             "closed_object" => descriptor["fields"]
    179                 .as_object()
    180                 .unwrap()
    181                 .values()
    182                 .map(|field| field.as_str().unwrap())
    183                 .collect(),
    184             "tagged_union" => descriptor["variants"]
    185                 .as_array()
    186                 .unwrap()
    187                 .iter()
    188                 .map(|variant| variant.as_str().unwrap())
    189                 .collect(),
    190             "alias" => vec![descriptor["target"].as_str().unwrap()],
    191             "optional" => vec![descriptor["value"].as_str().unwrap()],
    192             "boolean"
    193             | "canonical_json_object"
    194             | "enum"
    195             | "integer"
    196             | "literal"
    197             | "string"
    198             | "string_union" => Vec::new(),
    199             kind => panic!("unknown descriptor kind {kind} for {type_name}"),
    200         };
    201         for reference in referenced {
    202             assert!(
    203                 types.contains_key(reference),
    204                 "type {type_name} references missing type {reference}"
    205             );
    206         }
    207     }
    208     assert_eq!(
    209         value["admin"]["identity_contract"]["roles"],
    210         serde_json::json!([
    211             { "id": "transport", "required": true, "disabled_allowed": false, "providers": ["encrypted_file", "local_signer"] },
    212             { "id": "user", "required": true, "disabled_allowed": false, "providers": ["encrypted_file", "local_signer"] },
    213             { "id": "discovery", "required": false, "disabled_allowed": true, "providers": ["encrypted_file", "local_signer"] }
    214         ])
    215     );
    216     assert_eq!(
    217         value["admin"]["types"]["service_phase"],
    218         serde_json::json!({
    219             "kind": "enum",
    220             "values": ["starting", "ready", "degraded", "unready", "stopping", "failed"]
    221         })
    222     );
    223     assert_eq!(
    224         value["admin"]["types"]["nip46_permission"],
    225         serde_json::json!({
    226             "kind": "string_union",
    227             "simple_values": ["nip04_decrypt", "nip04_encrypt", "nip44_decrypt", "nip44_encrypt", "switch_relays"],
    228             "sign_event_pattern": "^sign_event:kind:(0|[1-9][0-9]{0,9})$",
    229             "sign_event_kind_minimum": 0,
    230             "sign_event_kind_maximum": 4_294_967_295_u64,
    231             "sign_event_kind_encoding": "canonical_unsigned_decimal_no_leading_zeroes",
    232             "maximum_utf8_bytes": 64,
    233             "unpermissioned_methods": ["connect", "get_public_key", "get_session_capability", "ping", "logout"],
    234             "custom_methods": "unsupported_v1",
    235             "required_permission_api": "radroots_nostr_connect::server::required_permission",
    236             "permission_type": "radroots_nostr_connect::Permission",
    237             "fixed_vectors": {
    238                 "valid": ["sign_event:kind:0", "sign_event:kind:1", "sign_event:kind:4294967295", "switch_relays"],
    239                 "invalid": ["get_public_key", "ping", "sign_event", "sign_event:kind:", "sign_event:kind:00", "sign_event:kind:4294967296", "sign_event:kind:9999999999"]
    240             }
    241         })
    242     );
    243     for vector in value["admin"]["types"]["nip46_permission"]["fixed_vectors"]["valid"]
    244         .as_array()
    245         .unwrap()
    246     {
    247         assert!(permission_vector_is_valid(vector.as_str().unwrap()));
    248     }
    249     for vector in value["admin"]["types"]["nip46_permission"]["fixed_vectors"]["invalid"]
    250         .as_array()
    251         .unwrap()
    252     {
    253         assert!(!permission_vector_is_valid(vector.as_str().unwrap()));
    254     }
    255     assert_eq!(
    256         value["admin"]["models"]["service_status_v1"]["fields"]
    257             .as_object()
    258             .unwrap()
    259             .keys()
    260             .map(String::as_str)
    261             .collect::<Vec<_>>(),
    262         [
    263             "build_info",
    264             "configuration",
    265             "contract_version",
    266             "instance",
    267             "myc",
    268             "persistence",
    269             "phase",
    270             "provider",
    271             "ready",
    272             "reason_codes",
    273             "service",
    274             "transport",
    275             "uptime_millis"
    276         ]
    277     );
    278     assert_eq!(
    279         value["admin"]["identity_contract"],
    280         serde_json::json!({
    281             "roles": [
    282                 { "id": "transport", "required": true, "disabled_allowed": false, "providers": ["encrypted_file", "local_signer"] },
    283                 { "id": "user", "required": true, "disabled_allowed": false, "providers": ["encrypted_file", "local_signer"] },
    284                 { "id": "discovery", "required": false, "disabled_allowed": true, "providers": ["encrypted_file", "local_signer"] }
    285             ],
    286             "live_mutation": false,
    287             "rotation_mode": "offline_create_new_then_config_apply",
    288             "in_place_overwrite": false
    289         })
    290     );
    291     assert_eq!(
    292         value["admin"]["path_parameters"],
    293         serde_json::json!({
    294             "connection_id": { "type": "bounded_id", "source": "percent_decoded_single_path_segment", "slash_allowed": false },
    295             "challenge_id": { "type": "bounded_id", "source": "percent_decoded_single_path_segment", "slash_allowed": false }
    296         })
    297     );
    298     assert_eq!(
    299         value["admin"]["pagination"],
    300         serde_json::json!({
    301             "cursor_type": "page_cursor",
    302             "limit_min": 1,
    303             "limit_max": 200,
    304             "terminal_page": "next_cursor_field_absent",
    305             "cursor_reuse": "same_route_same_filters_only",
    306             "filter_or_route_mismatch": "invalid_cursor",
    307             "connections_order": ["created_at_utc_ascending", "connection_id_ascending"],
    308             "connections_snapshot": "generation_fixed_by_first_page_cursor",
    309             "audit_order": ["occurred_at_utc_descending", "audit_id_descending"],
    310             "audit_snapshot": "maximum_audit_sequence_fixed_by_first_page_cursor"
    311         })
    312     );
    313 
    314     let mutation_operations = routes
    315         .iter()
    316         .filter(|route| route["mutation"] == true)
    317         .map(|route| route["operation_id"].as_str().unwrap())
    318         .collect::<BTreeSet<_>>();
    319     let committed_effects = value["admin"]["mutation_contract"]["committed_effects"]
    320         .as_object()
    321         .unwrap()
    322         .keys()
    323         .map(String::as_str)
    324         .collect::<BTreeSet<_>>();
    325     assert_eq!(mutation_operations, committed_effects);
    326     assert_eq!(
    327         decision_sections_digest(&value),
    328         "4967401cbb7b777aa78e19e91c75fae9d1245a307bf37ea30611d30d8ffeebbd"
    329     );
    330 }
    331 
    332 #[test]
    333 fn doctor_exit_and_tcp_contracts_are_exact() {
    334     let value = contract();
    335     assert_eq!(
    336         value["doctor"]["shared_schema"],
    337         "radroots.service.doctor.v1"
    338     );
    339     assert_eq!(value["doctor"]["contract_version"], 1);
    340     assert_eq!(value["doctor"]["execution"], "ordered");
    341     assert_eq!(value["doctor"]["pass_requires_all_scope"], true);
    342     assert_eq!(
    343         value["doctor"]["probe_future_cancellation"],
    344         "drop_stops_or_owns_cleanup"
    345     );
    346     assert_eq!(value["doctor"]["detached_probe_work"], false);
    347     assert_eq!(
    348         value["doctor"]["statuses"],
    349         serde_json::json!(["pass", "fail", "timeout", "skipped"])
    350     );
    351     assert_eq!(
    352         value["doctor"]["aggregate_statuses"],
    353         serde_json::json!(["pass", "degraded", "fail"])
    354     );
    355     assert_eq!(value["doctor"]["required_skipped"], "forbidden");
    356     assert_eq!(value["doctor"]["summary_max_utf8_bytes"], 256);
    357     assert_eq!(value["doctor"]["report_max_utf8_bytes"], 8192);
    358     assert_eq!(value["doctor"]["raw_error_or_path_allowed"], false);
    359     assert_eq!(value["doctor"]["required_fail_or_timeout_exit"], 6);
    360     assert_eq!(
    361         value["doctor"]["checks"],
    362         serde_json::json!([
    363             { "id": "paths_permissions", "required": true, "deadline_ms": 2000, "remediation_code": "correct_path_policy", "scope": ["resolved_path_containment", "owner", "type", "mode"] },
    364             { "id": "writer_lock", "required": true, "deadline_ms": 2000, "remediation_code": "release_writer_lock", "scope": ["state_directory_binding", "writer_lock_state"] },
    365             { "id": "sqlite_schema", "required": true, "deadline_ms": 5000, "remediation_code": "repair_schema", "scope": ["metadata_identity", "migration_history", "schema_catalog"] },
    366             { "id": "sqlite_integrity", "required": true, "deadline_ms": 15000, "remediation_code": "restore_verified_state", "scope": ["integrity_check", "foreign_key_check"] },
    367             { "id": "sqlite_free_space", "required": true, "deadline_ms": 2000, "remediation_code": "free_state_disk_space", "scope": ["state_filesystem_capacity", "minimum_free_bytes"] },
    368             { "id": "identity_binding", "required": true, "deadline_ms": 2000, "remediation_code": "restore_identity_binding", "scope": ["envelope_contract", "credential_reference", "public_identity"] },
    369             { "id": "signer_provider", "required": true, "deadline_ms": 15000, "remediation_code": "repair_signer_provider", "scope": ["capability", "contract_version", "identity", "correlation", "deadline"] },
    370             { "id": "admin_bind_policy", "required": true, "deadline_ms": 2000, "remediation_code": "correct_admin_bind_policy", "scope": ["unix_socket_path", "socket_mode", "peer_authorization"] },
    371             { "id": "operations_bind_policy", "required": true, "deadline_ms": 2000, "remediation_code": "correct_operations_bind_policy", "scope": ["enabled_posture", "listen_address", "bind_policy"] },
    372             { "id": "network_policy", "required": true, "deadline_ms": 2000, "remediation_code": "correct_network_policy", "scope": ["dns_policy", "tls_policy", "relay_url_policy"] },
    373             { "id": "required_relays", "required": true, "deadline_ms": 15000, "remediation_code": "restore_required_relays", "scope": ["required_read_relays", "required_write_relays", "connect_deadline"] },
    374             { "id": "outbox_invariants", "required": true, "deadline_ms": 5000, "remediation_code": "repair_outbox_state", "scope": ["claim_invariants", "retry_state", "exact_response_bytes"] },
    375             { "id": "clock_skew", "required": false, "deadline_ms": 5000, "remediation_code": "correct_clock", "scope": ["wall_clock_skew"] }
    376         ])
    377     );
    378     assert_eq!(
    379         value["exit_codes"],
    380         serde_json::json!([
    381             { "code": 0, "name": "success", "meaning": "successful command or completed graceful first-signal shutdown" },
    382             { "code": 1, "name": "unexpected_internal", "meaning": "unexpected invariant, critical task, or internal failure" },
    383             { "code": 2, "name": "input_or_configuration", "meaning": "CLI, config, validation, or unsupported contract input" },
    384             { "code": 3, "name": "service_or_dependency_unavailable", "meaning": "daemon, required provider, relay, source, or local dependency unavailable" },
    385             { "code": 4, "name": "state_or_identity_unavailable", "meaning": "state, schema, lock, credential, or identity unavailable" },
    386             { "code": 5, "name": "operation_rejected_or_conflict", "meaning": "authorization rejection, idempotency conflict, stale generation, or domain conflict" },
    387             { "code": 6, "name": "doctor_required_check_failed", "meaning": "one or more required doctor checks failed or timed out" }
    388         ])
    389     );
    390     assert_eq!(
    391         value["tcp_operations"],
    392         serde_json::json!({
    393             "routes": [
    394                 { "method": "GET", "path": "/livez", "source": "cached_supervisor_state" },
    395                 { "method": "GET", "path": "/readyz", "source": "cached_readiness_state" },
    396                 { "method": "GET", "path": "/metrics", "source": "cached_bounded_metrics_snapshot" }
    397             ],
    398             "active_probe_per_request": false,
    399             "additional_routes": false
    400         })
    401     );
    402 }