myc

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

services_hardening_nip46_admission.rs (11674B)


      1 #![forbid(unsafe_code)]
      2 
      3 use std::error::Error;
      4 
      5 use myc::{
      6     MYC_NIP46_EVENT_ID_MAX_BYTES, MYC_NIP46_PUBLIC_KEY_MAX_BYTES, MYC_NIP46_SIGNATURE_MAX_BYTES,
      7     MycConfigProfile, MycNip46AdmissionErrorKind, MycNip46AdmissionLimits, admit_myc_nip46_event,
      8     admit_myc_nip46_request, parse_myc_config_v1,
      9 };
     10 use serde_json::{Value, json};
     11 
     12 const CONFIG: &str = include_str!("../contracts/services_hardening/config.v1.example.toml");
     13 const CONTRACT: &str = include_str!("../contracts/services_hardening/nip46_admission.v1.json");
     14 
     15 fn configuration(overrides: &[(&str, usize)]) -> myc::MycConfigDocumentV1 {
     16     let mut source = CONFIG.to_owned();
     17     for (field, value) in overrides {
     18         let prefix = format!("{field} = ");
     19         let original = source
     20             .lines()
     21             .find(|line| line.starts_with(&prefix))
     22             .expect("configured event limit")
     23             .to_owned();
     24         source = source.replacen(&original, &format!("{field} = {value}"), 1);
     25     }
     26     parse_myc_config_v1(source.as_bytes(), MycConfigProfile::RepoLocal).expect("test configuration")
     27 }
     28 
     29 fn limits(overrides: &[(&str, usize)]) -> MycNip46AdmissionLimits {
     30     MycNip46AdmissionLimits::from_config(&configuration(overrides)).expect("admission limits")
     31 }
     32 
     33 fn event(content: &str, tags: Value) -> Vec<u8> {
     34     serde_json::to_vec(&json!({
     35         "id": "11".repeat(32),
     36         "pubkey": "22".repeat(32),
     37         "created_at": 1_725_000_000_u64,
     38         "kind": 24_133_u64,
     39         "tags": tags,
     40         "content": content,
     41         "sig": "33".repeat(64)
     42     }))
     43     .expect("event JSON")
     44 }
     45 
     46 fn request(id: &str, method: &str, params: Value) -> Vec<u8> {
     47     serde_json::to_vec(&json!({"id": id, "method": method, "params": params}))
     48         .expect("request JSON")
     49 }
     50 
     51 fn event_error(bytes: &[u8], limits: MycNip46AdmissionLimits) -> MycNip46AdmissionErrorKind {
     52     admit_myc_nip46_event(limits, bytes)
     53         .expect_err("event must fail")
     54         .kind()
     55 }
     56 
     57 fn request_error(bytes: &[u8], limits: MycNip46AdmissionLimits) -> MycNip46AdmissionErrorKind {
     58     admit_myc_nip46_request(limits, bytes)
     59         .expect_err("request must fail")
     60         .kind()
     61 }
     62 
     63 #[test]
     64 fn machine_contract_and_config_projection_are_exact() {
     65     let contract: Value = serde_json::from_str(CONTRACT).expect("admission contract JSON");
     66     assert_eq!(contract["schema"], "radroots.myc.nip46-admission.v1");
     67     assert_eq!(contract["contract_version"], 1);
     68     assert_eq!(contract["event"]["original_wire_cap_before_parse"], true);
     69     assert_eq!(
     70         contract["event"]["decryption"],
     71         "forbidden_before_admission"
     72     );
     73     assert_eq!(contract["request"]["typed_decode"], "deferred_to_step_142");
     74 
     75     let limits = limits(&[]);
     76     assert_eq!(limits.event_wire_bytes(), 262_144);
     77     assert_eq!(limits.event_content_bytes(), 131_072);
     78     assert_eq!(limits.event_tag_count(), 1_024);
     79     assert_eq!(limits.event_tag_total_elements(), 4_096);
     80     assert_eq!(limits.event_tag_element_bytes(), 4_096);
     81     assert_eq!(limits.event_tag_total_bytes(), 131_072);
     82     assert_eq!(limits.decrypted_plaintext_bytes(), 262_144);
     83     assert_eq!(MYC_NIP46_EVENT_ID_MAX_BYTES, 64);
     84     assert_eq!(MYC_NIP46_PUBLIC_KEY_MAX_BYTES, 64);
     85     assert_eq!(MYC_NIP46_SIGNATURE_MAX_BYTES, 128);
     86 }
     87 
     88 #[test]
     89 fn event_admission_retains_exact_bytes_and_bounded_ciphertext_without_semantic_claims() {
     90     let bytes = event(
     91         "ciphertext-secret-marker",
     92         json!([["p", "44".repeat(32)], ["alt", "v"]]),
     93     );
     94     let admitted = admit_myc_nip46_event(limits(&[]), &bytes).expect("bounded event");
     95     assert_eq!(admitted.original_bytes(), bytes);
     96     assert_eq!(admitted.encrypted_content(), "ciphertext-secret-marker");
     97     assert_eq!(admitted.tag_count(), 2);
     98     assert_eq!(admitted.tag_element_count(), 4);
     99     assert_eq!(admitted.tag_bytes(), 1 + 64 + 3 + 1);
    100 
    101     let rendered = format!("{admitted:?}");
    102     assert!(!rendered.contains("ciphertext-secret-marker"));
    103     assert!(!rendered.contains(&"44".repeat(32)));
    104 }
    105 
    106 #[test]
    107 fn original_event_cap_precedes_utf8_json_and_allocation() {
    108     let cap = 128;
    109     let oversized = vec![0xff; cap + 1];
    110     assert_eq!(
    111         event_error(&oversized, limits(&[("wire_bytes", cap)])),
    112         MycNip46AdmissionErrorKind::EventTooLarge
    113     );
    114     assert_eq!(
    115         event_error(&[], limits(&[])),
    116         MycNip46AdmissionErrorKind::EmptyEvent
    117     );
    118     assert_eq!(
    119         event_error(&[0xff], limits(&[])),
    120         MycNip46AdmissionErrorKind::InvalidEventUtf8
    121     );
    122 }
    123 
    124 #[test]
    125 fn event_object_is_closed_duplicate_free_nonnull_and_exactly_consumed() {
    126     let valid = String::from_utf8(event("x", json!([]))).expect("UTF-8 event");
    127     let duplicate = valid.replacen("\"id\":", "\"id\":\"11\",\"id\":", 1);
    128     let unknown = valid.replacen('{', "{\"extra\":1,", 1);
    129     let null = valid.replacen("\"content\":\"x\"", "\"content\":null", 1);
    130     for wire in [duplicate, unknown, null, format!("{valid} true")] {
    131         assert_eq!(
    132             event_error(wire.as_bytes(), limits(&[])),
    133             MycNip46AdmissionErrorKind::MalformedEvent
    134         );
    135     }
    136 }
    137 
    138 #[test]
    139 fn event_identifier_and_content_bounds_count_decoded_utf8_bytes() {
    140     let base = String::from_utf8(event("x", json!([]))).expect("event");
    141     for (field, maximum) in [
    142         ("id", MYC_NIP46_EVENT_ID_MAX_BYTES),
    143         ("pubkey", MYC_NIP46_PUBLIC_KEY_MAX_BYTES),
    144         ("sig", MYC_NIP46_SIGNATURE_MAX_BYTES),
    145     ] {
    146         let current = if field == "sig" {
    147             "33".repeat(64)
    148         } else if field == "pubkey" {
    149             "22".repeat(32)
    150         } else {
    151             "11".repeat(32)
    152         };
    153         let oversized = base.replacen(
    154             &format!("\"{field}\":\"{current}\""),
    155             &format!("\"{field}\":\"{}\"", "a".repeat(maximum + 1)),
    156             1,
    157         );
    158         assert_eq!(
    159             event_error(oversized.as_bytes(), limits(&[])),
    160             MycNip46AdmissionErrorKind::EventIdentifierTooLarge
    161         );
    162     }
    163 
    164     let exact = event("éé", json!([]));
    165     assert!(admit_myc_nip46_event(limits(&[("content_bytes", 4)]), &exact).is_ok());
    166     let over = event("ééa", json!([]));
    167     assert_eq!(
    168         event_error(&over, limits(&[("content_bytes", 4)])),
    169         MycNip46AdmissionErrorKind::EventContentTooLarge
    170     );
    171 
    172     let escaped_exact = base.replacen("\"content\":\"x\"", "\"content\":\"\\u00e9\\u00e9\"", 1);
    173     assert!(
    174         admit_myc_nip46_event(limits(&[("content_bytes", 4)]), escaped_exact.as_bytes()).is_ok()
    175     );
    176 }
    177 
    178 #[test]
    179 fn tag_count_element_and_aggregate_bounds_fail_independently() {
    180     let exact_count = event("x", json!([["a"], ["b"]]));
    181     assert!(admit_myc_nip46_event(limits(&[("tag_count", 2)]), &exact_count).is_ok());
    182     let over_count = event("x", json!([["a"], ["b"], ["c"]]));
    183     assert_eq!(
    184         event_error(&over_count, limits(&[("tag_count", 2)])),
    185         MycNip46AdmissionErrorKind::TooManyTags
    186     );
    187 
    188     let exact_elements = event("x", json!([["a", "b"], ["c"]]));
    189     assert!(admit_myc_nip46_event(limits(&[("tag_total_elements", 3)]), &exact_elements).is_ok());
    190     let over_elements = event("x", json!([["a", "b"], ["c", "d"]]));
    191     assert_eq!(
    192         event_error(&over_elements, limits(&[("tag_total_elements", 3)])),
    193         MycNip46AdmissionErrorKind::TooManyTagElements
    194     );
    195 
    196     let exact_element = event("x", json!([["éé"]]));
    197     assert!(admit_myc_nip46_event(limits(&[("tag_element_bytes", 4)]), &exact_element).is_ok());
    198     let over_element = event("x", json!([["ééa"]]));
    199     assert_eq!(
    200         event_error(&over_element, limits(&[("tag_element_bytes", 4)])),
    201         MycNip46AdmissionErrorKind::TagElementTooLarge
    202     );
    203 
    204     let exact_total = event("x", json!([["ab"], ["cd"]]));
    205     assert!(admit_myc_nip46_event(limits(&[("tag_total_bytes", 4)]), &exact_total).is_ok());
    206     let over_total = event("x", json!([["ab"], ["cde"]]));
    207     assert_eq!(
    208         event_error(&over_total, limits(&[("tag_total_bytes", 4)])),
    209         MycNip46AdmissionErrorKind::TagsTooLarge
    210     );
    211 
    212     for malformed in [event("x", json!(["not-a-tag"])), event("x", json!([[1]]))] {
    213         assert_eq!(
    214             event_error(&malformed, limits(&[])),
    215             MycNip46AdmissionErrorKind::MalformedEvent
    216         );
    217     }
    218 }
    219 
    220 #[test]
    221 fn request_plaintext_cap_precedes_utf8_json_and_content_exposure() {
    222     let cap = 128;
    223     let oversized = vec![0xff; cap + 1];
    224     assert_eq!(
    225         request_error(&oversized, limits(&[("decrypted_plaintext_bytes", cap)])),
    226         MycNip46AdmissionErrorKind::PlaintextTooLarge
    227     );
    228     assert_eq!(
    229         request_error(&[], limits(&[])),
    230         MycNip46AdmissionErrorKind::EmptyPlaintext
    231     );
    232     assert_eq!(
    233         request_error(&[0xff], limits(&[])),
    234         MycNip46AdmissionErrorKind::InvalidPlaintextUtf8
    235     );
    236 
    237     let bytes = request("request-secret-marker", "ping", json!([]));
    238     let admitted = admit_myc_nip46_request(limits(&[]), &bytes).expect("bounded request");
    239     assert_eq!(admitted.plaintext_bytes(), bytes.len());
    240     assert_eq!(admitted.request_id_bytes(), 21);
    241     assert_eq!(admitted.method_bytes(), 4);
    242     assert_eq!(admitted.parameter_count(), 0);
    243     let rendered = format!("{admitted:?}");
    244     assert!(!rendered.contains("request-secret-marker"));
    245     assert!(!rendered.contains("ping"));
    246 }
    247 
    248 #[test]
    249 fn request_object_identifiers_and_parameters_are_strict_and_bounded() {
    250     let valid = String::from_utf8(request("id", "ping", json!([]))).expect("request");
    251     let duplicate = valid.replacen("\"id\":", "\"id\":\"other\",\"id\":", 1);
    252     let unknown = valid.replacen('{', "{\"extra\":1,", 1);
    253     let null = valid.replacen("\"params\":[]", "\"params\":null", 1);
    254     for wire in [duplicate, unknown, null, format!("{valid} false")] {
    255         assert_eq!(
    256             request_error(wire.as_bytes(), limits(&[])),
    257             MycNip46AdmissionErrorKind::MalformedRequest
    258         );
    259     }
    260 
    261     assert_eq!(
    262         request_error(&request(&"i".repeat(129), "ping", json!([])), limits(&[])),
    263         MycNip46AdmissionErrorKind::RequestIdentifierTooLarge
    264     );
    265     assert_eq!(
    266         request_error(&request("id", &"m".repeat(65), json!([])), limits(&[])),
    267         MycNip46AdmissionErrorKind::RequestMethodTooLarge
    268     );
    269 
    270     let exact_count = request("id", "custom", json!(vec!["x"; 64]));
    271     assert!(admit_myc_nip46_request(limits(&[]), &exact_count).is_ok());
    272     let over_count = request("id", "custom", json!(vec!["x"; 65]));
    273     assert_eq!(
    274         request_error(&over_count, limits(&[])),
    275         MycNip46AdmissionErrorKind::TooManyRequestParameters
    276     );
    277 
    278     let exact_parameter = request("id", "custom", json!(["x".repeat(65_536)]));
    279     assert!(admit_myc_nip46_request(limits(&[]), &exact_parameter).is_ok());
    280     let over_parameter = request("id", "custom", json!(["x".repeat(65_537)]));
    281     assert_eq!(
    282         request_error(&over_parameter, limits(&[])),
    283         MycNip46AdmissionErrorKind::RequestParameterTooLarge
    284     );
    285 
    286     assert_eq!(
    287         request_error(&request("id", "custom", json!([1])), limits(&[])),
    288         MycNip46AdmissionErrorKind::MalformedRequest
    289     );
    290 }
    291 
    292 #[test]
    293 fn public_errors_are_source_free_and_diagnostics_never_render_input() {
    294     let marker = "protected-plaintext-marker";
    295     let bytes = request(marker, "ping", json!([]));
    296     let error = admit_myc_nip46_request(limits(&[("decrypted_plaintext_bytes", 1)]), &bytes)
    297         .expect_err("oversized plaintext");
    298     assert_eq!(error.kind(), MycNip46AdmissionErrorKind::PlaintextTooLarge);
    299     assert!(error.source().is_none());
    300     let rendered = format!("{error} {error:?}");
    301     assert!(!rendered.contains(marker));
    302     assert!(!rendered.contains(&String::from_utf8_lossy(&bytes).into_owned()));
    303 }