wire.rs (19501B)
1 //! Bounded, proof-carrying storage wire for authored event plans. 2 3 #[cfg(all(not(feature = "std"), feature = "json"))] 4 use alloc::string::ToString; 5 #[cfg(not(feature = "std"))] 6 use alloc::{borrow::ToOwned, string::String, vec::Vec}; 7 #[cfg(feature = "std")] 8 use std::{borrow::ToOwned, string::String, vec::Vec}; 9 10 use core::fmt; 11 #[cfg(feature = "json")] 12 use radroots_event::{ 13 contract::{ContractId, EventAuthoringPolicy, validate_event_contract_parts}, 14 envelope::EventTags, 15 wire::compute_canonical_nip01_event_id, 16 }; 17 use radroots_event::{ 18 contract::{ContractIdentityError, ContractKey, RegistryVersion}, 19 id::EventId, 20 wire::v1::DEFAULT_RAW_JSON_MAX_BYTES, 21 }; 22 use radroots_identity::PublicKey; 23 24 #[cfg(feature = "json")] 25 use super::{AuthoredEventBody, typed::validate_historical_typed_profile}; 26 use super::{AuthoredEventPlan, PLAN_WIRE_VERSION_V1, PlanDigest, PlanDigestError}; 27 28 /// Hard byte limit applied before JSON parsing or field allocation. 29 pub const PLAN_WIRE_MAX_BYTES: usize = DEFAULT_RAW_JSON_MAX_BYTES; 30 31 /// Exact version-one durable representation of an authored event plan. 32 /// 33 /// Construction is limited to validated plans. Deserialization is intentionally 34 /// implemented only through [`Self::from_json`], which validates the embedded 35 /// historical registry profile and both cryptographic commitments. 36 #[derive(Clone, Debug, PartialEq, Eq)] 37 pub struct PlanWireV1 { 38 schema_version: u32, 39 contract: ContractKey, 40 expected_author: PublicKey, 41 created_at: u64, 42 kind: u32, 43 tags: Vec<Vec<String>>, 44 content: String, 45 expected_event_id: EventId, 46 plan_digest: PlanDigest, 47 } 48 49 impl PlanWireV1 { 50 #[must_use] 51 pub fn from_plan(plan: &AuthoredEventPlan) -> Self { 52 Self { 53 schema_version: PLAN_WIRE_VERSION_V1, 54 contract: plan.body().contract().clone(), 55 expected_author: *plan.author(), 56 created_at: plan.created_at(), 57 kind: plan.body().kind(), 58 tags: plan.body().tags().to_vec(), 59 content: plan.body().content().to_owned(), 60 expected_event_id: *plan.expected_event_id(), 61 plan_digest: plan.digest(), 62 } 63 } 64 65 #[must_use] 66 pub const fn schema_version(&self) -> u32 { 67 self.schema_version 68 } 69 70 #[must_use] 71 pub const fn contract(&self) -> &ContractKey { 72 &self.contract 73 } 74 75 #[must_use] 76 pub const fn expected_author(&self) -> &PublicKey { 77 &self.expected_author 78 } 79 80 #[must_use] 81 pub const fn created_at(&self) -> u64 { 82 self.created_at 83 } 84 85 #[must_use] 86 pub const fn kind(&self) -> u32 { 87 self.kind 88 } 89 90 #[must_use] 91 pub fn tags(&self) -> &[Vec<String>] { 92 &self.tags 93 } 94 95 #[must_use] 96 pub fn content(&self) -> &str { 97 &self.content 98 } 99 100 #[must_use] 101 pub const fn expected_event_id(&self) -> &EventId { 102 &self.expected_event_id 103 } 104 105 #[must_use] 106 pub const fn plan_digest(&self) -> PlanDigest { 107 self.plan_digest 108 } 109 110 #[cfg(feature = "json")] 111 pub fn to_json(&self) -> Result<Vec<u8>, PlanDecodeError> { 112 serde_json::to_vec(self).map_err(|error| PlanDecodeError::Json(error.to_string())) 113 } 114 115 #[cfg(feature = "json")] 116 pub fn from_json(bytes: &[u8]) -> Result<HistoricalPlanIntegrity, PlanDecodeError> { 117 if bytes.len() > PLAN_WIRE_MAX_BYTES { 118 return Err(PlanDecodeError::RawJsonTooLarge { 119 max: PLAN_WIRE_MAX_BYTES, 120 actual: bytes.len(), 121 }); 122 } 123 let wire = serde_json::from_slice::<RawPlanWireV1>(bytes) 124 .map_err(|error| PlanDecodeError::Json(error.to_string()))?; 125 Self::validate_raw(wire) 126 } 127 128 #[cfg(feature = "json")] 129 fn validate_raw(wire: RawPlanWireV1) -> Result<HistoricalPlanIntegrity, PlanDecodeError> { 130 if wire.schema_version != PLAN_WIRE_VERSION_V1 { 131 return Err(PlanDecodeError::UnsupportedSchemaVersion { 132 actual: wire.schema_version, 133 }); 134 } 135 136 let registry_version = RegistryVersion::new(wire.contract_registry_version) 137 .map_err(PlanDecodeError::ContractIdentity)?; 138 let contract_id = 139 ContractId::parse(wire.contract_id).map_err(PlanDecodeError::ContractIdentity)?; 140 let contract = ContractKey::new(registry_version, contract_id) 141 .map_err(PlanDecodeError::ContractIdentity)?; 142 let definition = contract.contract(); 143 if definition.kind != wire.kind { 144 return Err(PlanDecodeError::ContractKindMismatch { 145 expected: definition.kind, 146 actual: wire.kind, 147 }); 148 } 149 match definition.authoring_policy() { 150 EventAuthoringPolicy::GenericDraft => { 151 validate_event_contract_parts(wire.kind, &wire.tags, &wire.content, definition.id) 152 .map_err(|error| PlanDecodeError::HistoricalShape(error.code().to_owned()))?; 153 } 154 EventAuthoringPolicy::TypedOnly => validate_historical_typed_profile( 155 definition.id, 156 wire.created_at, 157 &wire.expected_author, 158 wire.kind, 159 &wire.tags, 160 &wire.content, 161 ) 162 .map_err(PlanDecodeError::HistoricalShape)?, 163 EventAuthoringPolicy::ReadOnly => { 164 return Err(PlanDecodeError::HistoricalProfileUnavailable { 165 contract_id: definition.id.to_owned(), 166 }); 167 } 168 } 169 EventTags::new(wire.tags.clone()) 170 .map_err(|error| PlanDecodeError::HistoricalShape(error.to_string()))?; 171 let expected_author = PublicKey::from_hex(&wire.expected_author) 172 .map_err(|error| PlanDecodeError::ExpectedAuthor(error.to_string()))?; 173 if expected_author.to_hex() != wire.expected_author { 174 return Err(PlanDecodeError::NonCanonicalExpectedAuthor); 175 } 176 let expected_event_id = EventId::parse(&wire.expected_event_id) 177 .map_err(|error| PlanDecodeError::ExpectedEventId(error.to_string()))?; 178 if expected_event_id.to_hex() != wire.expected_event_id { 179 return Err(PlanDecodeError::NonCanonicalExpectedEventId); 180 } 181 let recomputed_event_id = compute_canonical_nip01_event_id( 182 &wire.expected_author, 183 wire.created_at, 184 wire.kind, 185 &wire.tags, 186 &wire.content, 187 ) 188 .map_err(|error| PlanDecodeError::ExpectedEventId(error.to_string()))?; 189 if recomputed_event_id != expected_event_id { 190 return Err(PlanDecodeError::EventIdMismatch { 191 declared: expected_event_id.to_hex(), 192 computed: recomputed_event_id.to_hex(), 193 }); 194 } 195 196 let declared_digest = 197 PlanDigest::parse_hex(&wire.plan_digest).map_err(PlanDecodeError::PlanDigest)?; 198 let body = AuthoredEventBody { 199 contract: contract.clone(), 200 kind: wire.kind, 201 tags: wire.tags, 202 content: wire.content, 203 }; 204 let plan = AuthoredEventPlan::from_validated_parts( 205 body, 206 expected_author, 207 wire.created_at, 208 expected_event_id, 209 ); 210 if plan.digest() != declared_digest { 211 return Err(PlanDecodeError::PlanDigestMismatch { 212 declared: declared_digest.to_hex(), 213 computed: plan.digest().to_hex(), 214 }); 215 } 216 217 Ok(HistoricalPlanIntegrity { plan }) 218 } 219 } 220 221 /// Proof that a plan reconstructed successfully under its embedded registry. 222 #[derive(Clone, Debug, PartialEq, Eq)] 223 pub struct HistoricalPlanIntegrity { 224 plan: AuthoredEventPlan, 225 } 226 227 impl HistoricalPlanIntegrity { 228 #[must_use] 229 pub const fn plan(&self) -> &AuthoredEventPlan { 230 &self.plan 231 } 232 233 #[must_use] 234 pub fn into_plan(self) -> AuthoredEventPlan { 235 self.plan 236 } 237 238 /// Compares registry versions for policy reporting only. 239 /// 240 /// This relation is not current signing authorization. Signing must obtain 241 /// an explicit authorization decision from `radroots_signing`. 242 #[must_use] 243 pub fn registry_relation(&self, current: RegistryVersion) -> PlanRegistryRelation { 244 if self.plan.body().contract().registry_version() == current { 245 PlanRegistryRelation::Current 246 } else { 247 PlanRegistryRelation::Historical 248 } 249 } 250 } 251 252 /// Informational relation between an intact historical plan and current policy. 253 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 254 pub enum PlanRegistryRelation { 255 Current, 256 Historical, 257 } 258 259 #[derive(Clone, Debug, PartialEq, Eq)] 260 pub enum PlanDecodeError { 261 RawJsonTooLarge { max: usize, actual: usize }, 262 Json(String), 263 UnsupportedSchemaVersion { actual: u32 }, 264 ContractIdentity(ContractIdentityError), 265 ContractKindMismatch { expected: u32, actual: u32 }, 266 HistoricalProfileUnavailable { contract_id: String }, 267 HistoricalShape(String), 268 ExpectedAuthor(String), 269 NonCanonicalExpectedAuthor, 270 ExpectedEventId(String), 271 NonCanonicalExpectedEventId, 272 EventIdMismatch { declared: String, computed: String }, 273 PlanDigest(PlanDigestError), 274 PlanDigestMismatch { declared: String, computed: String }, 275 } 276 277 impl fmt::Display for PlanDecodeError { 278 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 279 match self { 280 Self::RawJsonTooLarge { max, actual } => { 281 write!(formatter, "plan wire is {actual} bytes; max is {max}") 282 } 283 Self::Json(error) => write!(formatter, "plan wire JSON is invalid: {error}"), 284 Self::UnsupportedSchemaVersion { actual } => { 285 write!(formatter, "unsupported plan wire schema version {actual}") 286 } 287 Self::ContractIdentity(error) => write!(formatter, "invalid contract key: {error}"), 288 Self::ContractKindMismatch { expected, actual } => write!( 289 formatter, 290 "plan kind {actual} does not match historical contract kind {expected}" 291 ), 292 Self::HistoricalProfileUnavailable { contract_id } => write!( 293 formatter, 294 "historical authoring profile unavailable for `{contract_id}`" 295 ), 296 Self::HistoricalShape(error) => { 297 write!(formatter, "historical plan shape is invalid: {error}") 298 } 299 Self::ExpectedAuthor(error) => write!(formatter, "invalid expected author: {error}"), 300 Self::NonCanonicalExpectedAuthor => { 301 formatter.write_str("expected author is not canonical lowercase hexadecimal") 302 } 303 Self::ExpectedEventId(error) => write!(formatter, "invalid expected event ID: {error}"), 304 Self::NonCanonicalExpectedEventId => { 305 formatter.write_str("expected event ID is not canonical lowercase hexadecimal") 306 } 307 Self::EventIdMismatch { declared, computed } => write!( 308 formatter, 309 "plan event ID mismatch: declared {declared}, computed {computed}" 310 ), 311 Self::PlanDigest(error) => write!(formatter, "invalid plan digest: {error}"), 312 Self::PlanDigestMismatch { declared, computed } => write!( 313 formatter, 314 "plan digest mismatch: declared {declared}, computed {computed}" 315 ), 316 } 317 } 318 } 319 320 #[cfg(feature = "std")] 321 impl std::error::Error for PlanDecodeError {} 322 323 #[cfg(feature = "json")] 324 #[derive(serde::Deserialize)] 325 #[serde(deny_unknown_fields)] 326 struct RawPlanWireV1 { 327 schema_version: u32, 328 contract_registry_version: u32, 329 contract_id: String, 330 expected_author: String, 331 created_at: u64, 332 kind: u32, 333 tags: Vec<Vec<String>>, 334 content: String, 335 expected_event_id: String, 336 plan_digest: String, 337 } 338 339 #[cfg(feature = "serde")] 340 impl serde::Serialize for PlanWireV1 { 341 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 342 where 343 S: serde::Serializer, 344 { 345 use serde::ser::SerializeStruct; 346 347 let mut state = serializer.serialize_struct("PlanWireV1", 10)?; 348 state.serialize_field("schema_version", &self.schema_version)?; 349 state.serialize_field( 350 "contract_registry_version", 351 &self.contract.registry_version().get(), 352 )?; 353 state.serialize_field("contract_id", self.contract.contract_id().as_str())?; 354 state.serialize_field("expected_author", &self.expected_author.to_hex())?; 355 state.serialize_field("created_at", &self.created_at)?; 356 state.serialize_field("kind", &self.kind)?; 357 state.serialize_field("tags", &self.tags)?; 358 state.serialize_field("content", &self.content)?; 359 state.serialize_field("expected_event_id", &self.expected_event_id.to_hex())?; 360 state.serialize_field("plan_digest", &self.plan_digest.to_hex())?; 361 state.end() 362 } 363 } 364 365 #[cfg(test)] 366 mod tests { 367 use super::*; 368 use radroots_event::{GenericEventDraft, envelope::kind::KIND_GEOCHAT}; 369 use serde_json::{Value, json}; 370 371 const ALICE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; 372 373 fn plan() -> AuthoredEventPlan { 374 AuthoredEventPlan::from_generic( 375 GenericEventDraft::new( 376 "radroots.social.geochat.v1", 377 KIND_GEOCHAT, 378 1_700_000_000, 379 vec![vec!["g".to_owned(), "u4pru".to_owned()]], 380 "hello 🍓", 381 ALICE, 382 ) 383 .expect("generic draft"), 384 ) 385 .expect("authored plan") 386 } 387 388 fn wire_json() -> Vec<u8> { 389 PlanWireV1::from_plan(&plan()).to_json().expect("wire JSON") 390 } 391 392 fn mutate(mutator: impl FnOnce(&mut Value)) -> Vec<u8> { 393 let mut value = serde_json::from_slice::<Value>(&wire_json()).expect("wire value"); 394 mutator(&mut value); 395 serde_json::to_vec(&value).expect("mutated wire") 396 } 397 398 #[test] 399 fn valid_plan_round_trips_with_exact_ordered_storage_wire() { 400 let plan = plan(); 401 let wire = PlanWireV1::from_plan(&plan); 402 let json = wire.to_json().expect("wire JSON"); 403 let expected = format!( 404 concat!( 405 "{{\"schema_version\":1,", 406 "\"contract_registry_version\":7,", 407 "\"contract_id\":\"radroots.social.geochat.v1\",", 408 "\"expected_author\":\"{}\",", 409 "\"created_at\":1700000000,", 410 "\"kind\":20000,", 411 "\"tags\":[[\"g\",\"u4pru\"]],", 412 "\"content\":\"hello 🍓\",", 413 "\"expected_event_id\":\"{}\",", 414 "\"plan_digest\":\"{}\"}}" 415 ), 416 ALICE, 417 plan.expected_event_id().to_hex(), 418 plan.digest().to_hex(), 419 ); 420 assert_eq!(String::from_utf8(json.clone()).expect("UTF-8"), expected); 421 422 let integrity = PlanWireV1::from_json(&json).expect("historically valid plan"); 423 assert_eq!(integrity.plan(), &plan); 424 assert_eq!( 425 integrity.registry_relation(RegistryVersion::CURRENT), 426 PlanRegistryRelation::Current 427 ); 428 assert_eq!( 429 integrity.registry_relation(RegistryVersion::new(8).expect("simulated advance")), 430 PlanRegistryRelation::Historical 431 ); 432 assert_eq!(PlanWireV1::from_plan(integrity.plan()), wire); 433 } 434 435 #[test] 436 fn unknown_duplicate_oversize_and_unsupported_versions_fail_closed() { 437 let unknown = mutate(|value| value["unknown"] = json!(true)); 438 assert!(matches!( 439 PlanWireV1::from_json(&unknown), 440 Err(PlanDecodeError::Json(_)) 441 )); 442 443 let json = String::from_utf8(wire_json()).expect("UTF-8"); 444 let duplicate = json.replacen( 445 "{\"schema_version\":1,", 446 "{\"schema_version\":1,\"schema_version\":1,", 447 1, 448 ); 449 assert!(matches!( 450 PlanWireV1::from_json(duplicate.as_bytes()), 451 Err(PlanDecodeError::Json(_)) 452 )); 453 assert_eq!( 454 PlanWireV1::from_json(&vec![b' '; PLAN_WIRE_MAX_BYTES + 1]), 455 Err(PlanDecodeError::RawJsonTooLarge { 456 max: PLAN_WIRE_MAX_BYTES, 457 actual: PLAN_WIRE_MAX_BYTES + 1, 458 }) 459 ); 460 461 let schema = mutate(|value| value["schema_version"] = json!(2)); 462 assert_eq!( 463 PlanWireV1::from_json(&schema), 464 Err(PlanDecodeError::UnsupportedSchemaVersion { actual: 2 }) 465 ); 466 let registry = mutate(|value| value["contract_registry_version"] = json!(8)); 467 assert!(matches!( 468 PlanWireV1::from_json(®istry), 469 Err(PlanDecodeError::ContractIdentity( 470 ContractIdentityError::UnsupportedRegistryVersion { actual: 8 } 471 )) 472 )); 473 } 474 475 #[test] 476 fn stale_or_tampered_commitments_and_noncanonical_identifiers_fail_closed() { 477 let content = mutate(|value| value["content"] = json!("tampered")); 478 assert!(matches!( 479 PlanWireV1::from_json(&content), 480 Err(PlanDecodeError::EventIdMismatch { .. }) 481 )); 482 let id = mutate(|value| { 483 value["expected_event_id"] = 484 json!("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); 485 }); 486 assert!(matches!( 487 PlanWireV1::from_json(&id), 488 Err(PlanDecodeError::EventIdMismatch { .. }) 489 )); 490 let digest = mutate(|value| { 491 value["plan_digest"] = 492 json!("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); 493 }); 494 assert!(matches!( 495 PlanWireV1::from_json(&digest), 496 Err(PlanDecodeError::PlanDigestMismatch { .. }) 497 )); 498 let uppercase_digest = mutate(|value| { 499 value["plan_digest"] = json!(plan().digest().to_hex().to_uppercase()); 500 }); 501 assert_eq!( 502 PlanWireV1::from_json(&uppercase_digest), 503 Err(PlanDecodeError::PlanDigest(PlanDigestError)) 504 ); 505 let kind = mutate(|value| value["kind"] = json!(1)); 506 assert_eq!( 507 PlanWireV1::from_json(&kind), 508 Err(PlanDecodeError::ContractKindMismatch { 509 expected: KIND_GEOCHAT, 510 actual: 1, 511 }) 512 ); 513 } 514 515 #[test] 516 fn typed_contract_rejects_a_shape_from_the_wrong_historical_profile() { 517 let typed = mutate(|value| { 518 value["contract_id"] = json!("radroots.social.update.v1"); 519 value["kind"] = json!(1); 520 value["tags"] = json!([["g", "u4pru"]]); 521 value["content"] = json!("hello"); 522 }); 523 assert_eq!( 524 PlanWireV1::from_json(&typed), 525 Err(PlanDecodeError::HistoricalShape( 526 "update_tags_forbidden".to_owned() 527 )) 528 ); 529 } 530 531 #[test] 532 fn adversarial_truncation_and_single_byte_mutation_never_panic() { 533 let wire = wire_json(); 534 for end in 0..wire.len() { 535 let _ = PlanWireV1::from_json(&wire[..end]); 536 } 537 for index in 0..wire.len() { 538 let mut mutated = wire.clone(); 539 mutated[index] = b'?'; 540 let _ = PlanWireV1::from_json(&mutated); 541 } 542 } 543 }