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 }