types.rs (21086B)
1 //! Low-level Nostr protocol values and checked generic authoring requests. 2 //! 3 //! This compatibility-oriented module does not grant typed Radroots product 4 //! admission. Prefer the focused `event`, `key`, `tag`, and `events` modules 5 //! for new code. 6 7 #![forbid(unsafe_code)] 8 9 #[cfg(all(feature = "events", feature = "std"))] 10 use crate::error::Error; 11 #[cfg(all(feature = "events", feature = "std"))] 12 use radroots_event::listing::classified::{ 13 ClassifiedListingPartition, classify_classified_listing_marker_names, 14 }; 15 #[cfg(feature = "events")] 16 use radroots_event_codec::authoring::AuthoredEventPlan; 17 18 #[cfg(feature = "events")] 19 pub(crate) use crate::event::Event as RadrootsNostrEvent; 20 #[cfg(feature = "events")] 21 pub(crate) use crate::event::Metadata as RadrootsNostrMetadata; 22 #[cfg(feature = "events")] 23 pub(crate) use crate::event::{ 24 EventId as RadrootsNostrEventId, Kind as RadrootsNostrKind, Timestamp as RadrootsNostrTimestamp, 25 }; 26 #[cfg(feature = "events")] 27 pub(crate) use crate::filter::Filter as RadrootsNostrFilter; 28 pub(crate) use crate::tag::{ 29 Tag as RadrootsNostrTag, TagKind as RadrootsNostrTagKind, 30 TagStandard as RadrootsNostrTagStandard, 31 }; 32 #[cfg(feature = "events")] 33 pub(crate) type RadrootsNostrEventBuilderUnchecked = nostr::EventBuilder; 34 #[cfg(feature = "events")] 35 pub(crate) type RadrootsNostrKeys = nostr::Keys; 36 #[cfg(feature = "events")] 37 pub(crate) type RadrootsNostrPublicKey = nostr::PublicKey; 38 pub(crate) type RadrootsNostrRelayUrl = nostr::RelayUrl; 39 40 /// A checked event prepared for an external signer. 41 /// 42 /// Generic requests are created only after generic authoring policy succeeds; 43 /// authored requests retain their immutable plan. Both serialize as the 44 /// standard Nostr unsigned-event object expected by signer helpers, but expose 45 /// no raw unsigned event, mutation, or unchecked deserialization boundary. 46 /// 47 /// ```compile_fail 48 /// use radroots_nostr::event::ExternalSigningRequest; 49 /// 50 /// let _: ExternalSigningRequest = 51 /// serde_json::from_str("{}").expect("request"); 52 /// ``` 53 #[must_use = "external signing requests must be completed by a signer"] 54 #[cfg(feature = "events")] 55 pub struct ExternalSigningRequest { 56 unsigned_event: nostr::UnsignedEvent, 57 expected_event_id: RadrootsNostrEventId, 58 expected_public_key: RadrootsNostrPublicKey, 59 authored_plan: Option<AuthoredEventPlan>, 60 } 61 62 #[cfg(feature = "events")] 63 impl ExternalSigningRequest { 64 /// Creates the exact standard unsigned request committed by an authored plan. 65 pub fn from_authored_plan(plan: AuthoredEventPlan) -> Result<Self, Error> { 66 let unsigned_event = crate::plan_signing::unsigned_event_from_plan(&plan)?; 67 let expected_event_id = unsigned_event 68 .id 69 .ok_or(Error::ExternalSigningPlanMismatch { 70 field: "expected_event_id", 71 })?; 72 let expected_public_key = unsigned_event.pubkey; 73 Ok(Self { 74 unsigned_event, 75 expected_event_id, 76 expected_public_key, 77 authored_plan: Some(plan), 78 }) 79 } 80 81 pub fn expected_event_id(&self) -> RadrootsNostrEventId { 82 self.expected_event_id 83 } 84 85 pub fn expected_public_key(&self) -> RadrootsNostrPublicKey { 86 self.expected_public_key 87 } 88 89 /// Returns the immutable authored plan for typed requests. 90 /// 91 /// Low-level generic interoperability requests have no product plan and 92 /// therefore return `None`. 93 pub const fn authored_plan(&self) -> Option<&AuthoredEventPlan> { 94 self.authored_plan.as_ref() 95 } 96 97 /// Accepts an external signing result only when it is the exact requested 98 /// event and its NIP-01 identifier and signature are valid. 99 #[cfg(feature = "std")] 100 pub fn complete(self, event: RadrootsNostrEvent) -> Result<RadrootsNostrEvent, Error> { 101 if event.pubkey != self.expected_public_key { 102 return Err(Error::ExternalSigningAuthorMismatch { 103 expected: self.expected_public_key, 104 actual: event.pubkey, 105 }); 106 } 107 if event.id != self.expected_event_id { 108 return Err(Error::ExternalSigningEventIdMismatch { 109 expected: self.expected_event_id, 110 actual: event.id, 111 }); 112 } 113 if let Some(plan) = &self.authored_plan { 114 crate::plan_signing::validate_signed_event_matches_plan(&event, plan)?; 115 } 116 event.verify().map_err(Error::ExternalSigningEventInvalid)?; 117 Ok(event) 118 } 119 120 #[cfg(feature = "std")] 121 pub(crate) fn sign_with_keys( 122 self, 123 keys: &RadrootsNostrKeys, 124 ) -> Result<RadrootsNostrEvent, Error> { 125 let event = self.unsigned_event.clone().sign_with_keys(keys)?; 126 self.complete(event) 127 } 128 } 129 130 #[cfg(feature = "events")] 131 impl serde::Serialize for ExternalSigningRequest { 132 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 133 where 134 S: serde::Serializer, 135 { 136 self.unsigned_event.serialize(serializer) 137 } 138 } 139 140 /// An opaque builder for generic Nostr events. 141 /// 142 /// Kind 0 profile events, all kind 1 events, all kind 5 deletion requests, kind 143 /// 1111 comments, and focused or mixed kind 30402 FoodAvailability marker 144 /// partitions are reserved for typed Radroots authoring. Marker-free NIP-99 145 /// and operational-only kind 30402 events remain available for compatibility. 146 /// The policy is enforced before direct signing and before a client is allowed 147 /// to consult its signer. 148 /// 149 /// The upstream unsigned builder is intentionally inaccessible: 150 /// 151 /// ```compile_fail 152 /// use radroots_nostr::event::{GenericBuilder, Kind}; 153 /// 154 /// let builder = GenericBuilder::new( 155 /// Kind::Custom(30_001), 156 /// "content", 157 /// ); 158 /// let _: nostr::EventBuilder = builder.into(); 159 /// ``` 160 /// 161 /// ```compile_fail 162 /// use radroots_nostr::event::{GenericBuilder, Kind}; 163 /// 164 /// let builder = GenericBuilder::new( 165 /// Kind::Custom(30_001), 166 /// "content", 167 /// ); 168 /// let _raw: nostr::EventBuilder = builder.into_inner(); 169 /// ``` 170 /// 171 /// ```compile_fail 172 /// use nostr::Keys; 173 /// use radroots_nostr::event::{GenericBuilder, Kind}; 174 /// 175 /// let builder = GenericBuilder::new( 176 /// Kind::Custom(30_001), 177 /// "content", 178 /// ); 179 /// let keys = Keys::generate(); 180 /// let _unsigned = builder.build(keys.public_key()); 181 /// ``` 182 #[must_use = "generic event builders must be signed or published"] 183 #[cfg(feature = "events")] 184 pub struct GenericBuilder { 185 inner: RadrootsNostrEventBuilderUnchecked, 186 } 187 188 #[cfg(feature = "events")] 189 impl GenericBuilder { 190 pub fn new(kind: RadrootsNostrKind, content: impl Into<alloc::string::String>) -> Self { 191 Self::from_unchecked(RadrootsNostrEventBuilderUnchecked::new(kind, content)) 192 } 193 194 pub fn text_note(content: impl Into<alloc::string::String>) -> Self { 195 Self::from_unchecked(RadrootsNostrEventBuilderUnchecked::text_note(content)) 196 } 197 198 pub fn tag(mut self, tag: RadrootsNostrTag) -> Self { 199 self.inner = self.inner.tag(tag); 200 self 201 } 202 203 pub fn tags<I>(mut self, tags: I) -> Self 204 where 205 I: IntoIterator<Item = RadrootsNostrTag>, 206 { 207 self.inner = self.inner.tags(tags); 208 self 209 } 210 211 pub fn custom_created_at(mut self, created_at: RadrootsNostrTimestamp) -> Self { 212 self.inner = self.inner.custom_created_at(created_at); 213 self 214 } 215 216 pub fn pow(mut self, difficulty: u8) -> Self { 217 self.inner = self.inner.pow(difficulty); 218 self 219 } 220 221 pub fn allow_self_tagging(mut self) -> Self { 222 self.inner = self.inner.allow_self_tagging(); 223 self 224 } 225 226 pub fn dedup_tags(mut self) -> Self { 227 self.inner = self.inner.dedup_tags(); 228 self 229 } 230 231 /// Signs a generic event after enforcing typed-authoring reservations. 232 #[cfg(feature = "std")] 233 pub fn sign_with_keys(self, keys: &RadrootsNostrKeys) -> Result<RadrootsNostrEvent, Error> { 234 self.into_external_signing_request(keys.public_key())? 235 .sign_with_keys(keys) 236 } 237 238 /// Finalizes a generic event for an external signer after enforcing typed 239 /// authoring reservations. 240 #[cfg(feature = "std")] 241 pub fn into_external_signing_request( 242 self, 243 public_key: RadrootsNostrPublicKey, 244 ) -> Result<ExternalSigningRequest, Error> { 245 self.validate_generic_authoring_policy()?; 246 let mut unsigned_event = self.inner.build(public_key); 247 let expected_event_id = unsigned_event.id(); 248 Ok(ExternalSigningRequest { 249 unsigned_event, 250 expected_event_id, 251 expected_public_key: public_key, 252 authored_plan: None, 253 }) 254 } 255 256 pub(crate) fn from_unchecked(inner: RadrootsNostrEventBuilderUnchecked) -> Self { 257 Self { inner } 258 } 259 260 #[cfg(feature = "std")] 261 fn validate_generic_authoring_policy(&self) -> Result<(), Error> { 262 // Inspect an unsigned clone so rejection never consults a signer. PoW 263 // is irrelevant to kind/tag policy and must not delay the check. 264 let mut inspection = self.inner.clone(); 265 inspection.custom_created_at = Some(RadrootsNostrTimestamp::from_secs(1)); 266 inspection.pow = None; 267 inspection.allow_self_tagging = true; 268 inspection.dedup_tags = false; 269 let inspection_pubkey = RadrootsNostrPublicKey::from_hex( 270 "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", 271 )?; 272 let event = inspection.build(inspection_pubkey); 273 let kind = event.kind.as_u16(); 274 let is_profile = kind == RadrootsNostrKind::Metadata.as_u16(); 275 let is_reserved_post = kind == RadrootsNostrKind::TextNote.as_u16(); 276 let is_reserved_deletion_request = 277 kind == radroots_event::envelope::kind::KIND_DELETION_REQUEST as u16; 278 let is_reserved_comment = kind == radroots_event::envelope::kind::KIND_COMMENT as u16; 279 let classified_listing_partition = 280 (kind == radroots_event::envelope::kind::KIND_CLASSIFIED_LISTING as u16).then(|| { 281 classify_classified_listing_marker_names( 282 event 283 .tags 284 .iter() 285 .map(|tag| tag.as_slice().first().map(|name| name.as_str())), 286 ) 287 }); 288 let is_reserved_focused_listing = matches!( 289 classified_listing_partition, 290 Some( 291 ClassifiedListingPartition::FocusedFoodAvailability 292 | ClassifiedListingPartition::Ambiguous 293 ) 294 ); 295 if is_profile 296 || is_reserved_post 297 || is_reserved_deletion_request 298 || is_reserved_comment 299 || is_reserved_focused_listing 300 { 301 return Err(Error::TypedAuthoringRequired { kind }); 302 } 303 Ok(()) 304 } 305 } 306 307 #[cfg(all(test, feature = "events"))] 308 mod tests { 309 use super::*; 310 311 fn keys() -> RadrootsNostrKeys { 312 RadrootsNostrKeys::generate() 313 } 314 315 #[test] 316 fn generic_direct_signing_rejects_typed_only_kinds() { 317 let keys = keys(); 318 319 for (builder, expected_kind) in [ 320 ( 321 GenericBuilder::new(RadrootsNostrKind::Metadata, "{}"), 322 RadrootsNostrKind::Metadata.as_u16(), 323 ), 324 ( 325 GenericBuilder::text_note("root post"), 326 RadrootsNostrKind::TextNote.as_u16(), 327 ), 328 ( 329 GenericBuilder::new( 330 RadrootsNostrKind::Custom( 331 radroots_event::envelope::kind::KIND_DELETION_REQUEST as u16, 332 ), 333 "Deletion request", 334 ), 335 radroots_event::envelope::kind::KIND_DELETION_REQUEST as u16, 336 ), 337 ( 338 GenericBuilder::new( 339 RadrootsNostrKind::Custom(radroots_event::envelope::kind::KIND_COMMENT as u16), 340 "Comment", 341 ), 342 radroots_event::envelope::kind::KIND_COMMENT as u16, 343 ), 344 ] { 345 assert!(matches!( 346 builder.sign_with_keys(&keys), 347 Err(Error::TypedAuthoringRequired { kind }) 348 if kind == expected_kind 349 )); 350 } 351 } 352 353 #[test] 354 fn generic_direct_signing_rejects_thread_kind_one() { 355 let error = GenericBuilder::text_note("reply") 356 .tag(RadrootsNostrTag::event(RadrootsNostrEventId::all_zeros())) 357 .sign_with_keys(&keys()) 358 .expect_err("all kind-1 authoring is typed"); 359 360 assert!(matches!( 361 error, 362 Error::TypedAuthoringRequired { kind } 363 if kind == RadrootsNostrKind::TextNote.as_u16() 364 )); 365 } 366 367 #[test] 368 fn generic_direct_signing_allows_non_reserved_kind() { 369 let event = GenericBuilder::new(RadrootsNostrKind::Custom(30_001), "generic") 370 .sign_with_keys(&keys()) 371 .expect("generic kind signs"); 372 373 assert_eq!(event.kind.as_u16(), 30_001); 374 } 375 376 #[test] 377 fn external_signing_request_serializes_as_canonical_unsigned_event() { 378 let keys = keys(); 379 let request = GenericBuilder::new(RadrootsNostrKind::Custom(24_133), "protocol") 380 .custom_created_at(RadrootsNostrTimestamp::from_secs(1_234)) 381 .into_external_signing_request(keys.public_key()) 382 .expect("checked request"); 383 let expected_event_id = request.expected_event_id(); 384 385 let encoded = serde_json::to_vec(&request).expect("serialize request"); 386 let unsigned_event: nostr::UnsignedEvent = 387 serde_json::from_slice(&encoded).expect("standard unsigned event"); 388 389 assert_eq!(unsigned_event.id, Some(expected_event_id)); 390 assert_eq!(unsigned_event.pubkey, keys.public_key()); 391 assert_eq!(unsigned_event.created_at.as_secs(), 1_234); 392 assert_eq!(unsigned_event.kind.as_u16(), 24_133); 393 assert_eq!(unsigned_event.content, "protocol"); 394 unsigned_event.verify_id().expect("canonical event id"); 395 } 396 397 #[test] 398 fn external_signing_request_rejects_reserved_authoring_before_finalization() { 399 let error = match GenericBuilder::text_note("reserved") 400 .into_external_signing_request(keys().public_key()) 401 { 402 Ok(_) => panic!("kind 1 remains typed-only"), 403 Err(error) => error, 404 }; 405 406 assert!(matches!( 407 error, 408 Error::TypedAuthoringRequired { kind } 409 if kind == RadrootsNostrKind::TextNote.as_u16() 410 )); 411 } 412 413 #[test] 414 fn external_signing_request_accepts_only_the_exact_valid_event() { 415 let keys = keys(); 416 let request = GenericBuilder::new(RadrootsNostrKind::Custom(24_133), "protocol") 417 .custom_created_at(RadrootsNostrTimestamp::from_secs(1_234)) 418 .into_external_signing_request(keys.public_key()) 419 .expect("checked request"); 420 let unsigned_event: nostr::UnsignedEvent = 421 serde_json::from_value(serde_json::to_value(&request).expect("request value")) 422 .expect("unsigned event"); 423 let valid_event = unsigned_event 424 .sign_with_keys(&keys) 425 .expect("valid signing result"); 426 427 let wrong_author = GenericBuilder::new(RadrootsNostrKind::Custom(24_133), "protocol") 428 .custom_created_at(RadrootsNostrTimestamp::from_secs(1_234)) 429 .sign_with_keys(&RadrootsNostrKeys::generate()) 430 .expect("other author event"); 431 assert!(matches!( 432 request.complete(wrong_author), 433 Err(Error::ExternalSigningAuthorMismatch { .. }) 434 )); 435 436 let request = GenericBuilder::new(RadrootsNostrKind::Custom(24_133), "protocol") 437 .custom_created_at(RadrootsNostrTimestamp::from_secs(1_234)) 438 .into_external_signing_request(keys.public_key()) 439 .expect("checked request"); 440 let wrong_event_id = GenericBuilder::new(RadrootsNostrKind::Custom(24_133), "different") 441 .sign_with_keys(&keys) 442 .expect("different event"); 443 assert!(matches!( 444 request.complete(wrong_event_id), 445 Err(Error::ExternalSigningEventIdMismatch { .. }) 446 )); 447 448 for mutate in [ 449 |event: &mut RadrootsNostrEvent| event.content.push_str(" tampered"), 450 |event: &mut RadrootsNostrEvent| { 451 event.kind = RadrootsNostrKind::Custom(24_134); 452 }, 453 |event: &mut RadrootsNostrEvent| { 454 event.tags = nostr::Tags::from_list(vec![RadrootsNostrTag::custom( 455 RadrootsNostrTagKind::custom("x"), 456 ["tampered"], 457 )]); 458 }, 459 ] { 460 let request = GenericBuilder::new(RadrootsNostrKind::Custom(24_133), "protocol") 461 .custom_created_at(RadrootsNostrTimestamp::from_secs(1_234)) 462 .into_external_signing_request(keys.public_key()) 463 .expect("checked request"); 464 let mut tampered = valid_event.clone(); 465 mutate(&mut tampered); 466 assert!(matches!( 467 request.complete(tampered), 468 Err(Error::ExternalSigningEventInvalid(_)) 469 )); 470 } 471 472 let other_signature = GenericBuilder::new(RadrootsNostrKind::Custom(24_133), "different") 473 .sign_with_keys(&keys) 474 .expect("other event") 475 .sig; 476 let request = GenericBuilder::new(RadrootsNostrKind::Custom(24_133), "protocol") 477 .custom_created_at(RadrootsNostrTimestamp::from_secs(1_234)) 478 .into_external_signing_request(keys.public_key()) 479 .expect("checked request"); 480 let mut invalid_signature = valid_event; 481 invalid_signature.sig = other_signature; 482 assert!(matches!( 483 request.complete(invalid_signature), 484 Err(Error::ExternalSigningEventInvalid(_)) 485 )); 486 } 487 488 #[test] 489 fn authored_plan_external_signing_preserves_and_checks_every_exact_field() { 490 use radroots_event::{GenericEventDraft, envelope::kind::KIND_GEOCHAT}; 491 use radroots_event_codec::authoring::AuthoredEventPlan; 492 493 let keys = keys(); 494 let author = keys.public_key().to_hex(); 495 let plan = AuthoredEventPlan::from_generic( 496 GenericEventDraft::new( 497 "radroots.social.geochat.v1", 498 KIND_GEOCHAT, 499 1_700_000_123, 500 vec![ 501 vec!["g".to_owned(), "u4pru".to_owned()], 502 vec!["p".to_owned(), author.clone()], 503 ], 504 " exact content\nš ", 505 &author, 506 ) 507 .expect("generic authored input"), 508 ) 509 .expect("authored plan"); 510 let request = ExternalSigningRequest::from_authored_plan(plan.clone()) 511 .expect("plan-backed signing request"); 512 assert_eq!(request.authored_plan(), Some(&plan)); 513 514 let unsigned: nostr::UnsignedEvent = 515 serde_json::from_value(serde_json::to_value(&request).expect("request JSON")) 516 .expect("standard unsigned request"); 517 assert_eq!(unsigned.id, Some(request.expected_event_id())); 518 assert_eq!(unsigned.pubkey, request.expected_public_key()); 519 assert_eq!(unsigned.created_at.as_secs(), plan.created_at()); 520 assert_eq!(u32::from(unsigned.kind.as_u16()), plan.body().kind()); 521 assert_eq!( 522 unsigned 523 .tags 524 .iter() 525 .map(|tag| tag.as_slice().to_vec()) 526 .collect::<Vec<_>>(), 527 plan.body().tags() 528 ); 529 assert_eq!(unsigned.content, plan.body().content()); 530 531 let valid = unsigned 532 .sign_with_keys(&keys) 533 .expect("external signer result"); 534 request.complete(valid.clone()).expect("exact completion"); 535 536 type PlanMutation = (&'static str, fn(&mut RadrootsNostrEvent)); 537 let mutations: [PlanMutation; 4] = [ 538 ("created_at", |event| { 539 event.created_at = RadrootsNostrTimestamp::from_secs(1_700_000_124); 540 }), 541 ("kind", |event| { 542 event.kind = RadrootsNostrKind::Custom(KIND_GEOCHAT as u16 + 1); 543 }), 544 ("tags", |event| { 545 event.tags = nostr::Tags::from_list(event.tags.iter().rev().cloned().collect()); 546 }), 547 ("content", |event| event.content.push_str("changed")), 548 ]; 549 for (field, mutate) in mutations { 550 let request = ExternalSigningRequest::from_authored_plan(plan.clone()) 551 .expect("plan-backed signing request"); 552 let mut tampered = valid.clone(); 553 mutate(&mut tampered); 554 assert!(matches!( 555 request.complete(tampered), 556 Err(Error::ExternalSigningPlanMismatch { field: actual }) if actual == field 557 )); 558 } 559 } 560 }