lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

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 }