Skip to main content

softchat/
event.rs

1use std::fmt;
2use std::str::FromStr;
3
4use nostr::event::Error as NostrEventError;
5use nostr::secp256k1::schnorr::Signature;
6use nostr::{Event, EventId, Keys, Kind, Tag, Timestamp, UnsignedEvent};
7use serde::{Deserialize, Serialize};
8
9use crate::diagnostics::{self, Operation};
10use crate::{NostrPublicKey, SoftchatError};
11
12/// Maximum standalone NIP-01 event JSON accepted by the portable boundary.
13///
14/// This matches Softrelay's current complete relay-message bound. Native
15/// transports should reject oversized frames before invoking this parser.
16pub const MAX_NOSTR_EVENT_JSON_BYTES: usize = 512 * 1024;
17
18/// Largest Unix timestamp represented exactly by JavaScript `number`.
19///
20/// Applying this effectively-unbounded date range to every target prevents a
21/// valid Rust, Kotlin, or Swift value from being silently rounded in
22/// JavaScript.
23pub const MAX_PORTABLE_TIMESTAMP_SECONDS: u64 = 9_007_199_254_740_991;
24
25/// A validated canonical lowercase hexadecimal Nostr event ID.
26#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
27pub struct NostrEventId {
28    inner: EventId,
29}
30
31impl NostrEventId {
32    /// Parse one 32-byte hexadecimal event ID and canonicalize it to lowercase.
33    ///
34    /// # Errors
35    ///
36    /// Returns [`SoftchatError::InvalidEventId`] for malformed input.
37    pub fn from_hex(value: &str) -> Result<Self, SoftchatError> {
38        if !is_hex_of_length(value, 64) {
39            return Err(SoftchatError::InvalidEventId);
40        }
41        let inner = EventId::from_hex(value).map_err(|_| SoftchatError::InvalidEventId)?;
42        Ok(Self { inner })
43    }
44
45    /// Return the canonical lowercase hexadecimal representation.
46    #[must_use]
47    pub fn to_hex(self) -> String {
48        self.inner.to_hex()
49    }
50
51    pub(crate) const fn from_inner(inner: EventId) -> Self {
52        Self { inner }
53    }
54
55    pub(crate) const fn as_inner(&self) -> &EventId {
56        &self.inner
57    }
58}
59
60impl fmt::Debug for NostrEventId {
61    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
62        formatter
63            .debug_tuple("NostrEventId")
64            .field(&self.to_hex())
65            .finish()
66    }
67}
68
69impl fmt::Display for NostrEventId {
70    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
71        formatter.write_str(&self.to_hex())
72    }
73}
74
75impl FromStr for NostrEventId {
76    type Err = SoftchatError;
77
78    fn from_str(value: &str) -> Result<Self, Self::Err> {
79        Self::from_hex(value)
80    }
81}
82
83/// A validated canonical lowercase BIP-340 Schnorr signature.
84#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
85pub struct NostrEventSignature {
86    inner: Signature,
87}
88
89impl NostrEventSignature {
90    /// Parse one 64-byte hexadecimal Schnorr signature.
91    ///
92    /// # Errors
93    ///
94    /// Returns [`SoftchatError::InvalidEventSignature`] for malformed input.
95    pub fn from_hex(value: &str) -> Result<Self, SoftchatError> {
96        if !is_hex_of_length(value, 128) {
97            return Err(SoftchatError::InvalidEventSignature);
98        }
99        let inner = Signature::from_str(value).map_err(|_| SoftchatError::InvalidEventSignature)?;
100        Ok(Self { inner })
101    }
102
103    /// Return the canonical lowercase hexadecimal representation.
104    #[must_use]
105    pub fn to_hex(self) -> String {
106        self.inner.to_string()
107    }
108
109    const fn from_inner(inner: Signature) -> Self {
110        Self { inner }
111    }
112
113    const fn as_inner(&self) -> &Signature {
114        &self.inner
115    }
116}
117
118impl fmt::Debug for NostrEventSignature {
119    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
120        formatter
121            .debug_tuple("NostrEventSignature")
122            .field(&self.to_hex())
123            .finish()
124    }
125}
126
127impl fmt::Display for NostrEventSignature {
128    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
129        formatter.write_str(&self.to_hex())
130    }
131}
132
133impl FromStr for NostrEventSignature {
134    type Err = SoftchatError;
135
136    fn from_str(value: &str) -> Result<Self, Self::Err> {
137        Self::from_hex(value)
138    }
139}
140
141/// A NIP-01 event kind in the inclusive `0...65535` range.
142#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
143pub struct NostrEventKind(u16);
144
145impl NostrEventKind {
146    /// NIP-01 user metadata (`kind 0`).
147    pub const USER_METADATA: Self = Self(0);
148    /// NIP-01 short text note (`kind 1`).
149    pub const SHORT_TEXT_NOTE: Self = Self(1);
150    /// NIP-02 follow list (`kind 3`).
151    pub const FOLLOW_LIST: Self = Self(3);
152    /// NIP-09 event deletion request (`kind 5`).
153    pub const EVENT_DELETION_REQUEST: Self = Self(5);
154    /// NIP-25 reaction (`kind 7`).
155    pub const REACTION: Self = Self(7);
156    /// NIP-59 seal (`kind 13`).
157    pub const SEAL: Self = Self(13);
158    /// NIP-17 private direct message rumor (`kind 14`).
159    pub const PRIVATE_DIRECT_MESSAGE: Self = Self(14);
160    /// NIP-18 generic repost (`kind 16`).
161    pub const GENERIC_REPOST: Self = Self(16);
162    /// Softchat updated-content event (`kind 1010`).
163    pub const UPDATED_CONTENT: Self = Self(1010);
164    /// NIP-59 durable gift wrap (`kind 1059`).
165    pub const GIFT_WRAP: Self = Self(1059);
166    /// NIP-94 file metadata (`kind 1063`).
167    pub const FILE_METADATA: Self = Self(1063);
168    /// NIP-59 ephemeral gift wrap (`kind 21059`).
169    pub const EPHEMERAL_GIFT_WRAP: Self = Self(21059);
170    /// Softchat typing event (`kind 21234`).
171    pub const TYPING: Self = Self(21234);
172    /// NIP-42 client authentication (`kind 22242`).
173    pub const CLIENT_AUTHENTICATION: Self = Self(22242);
174    /// NIP-98 HTTP authentication (`kind 27235`).
175    pub const HTTP_AUTHENTICATION: Self = Self(27235);
176    /// NIP-78 application-specific data (`kind 30078`).
177    pub const APPLICATION_DATA: Self = Self(30078);
178    /// Softchat application-data synchronization (`kind 30079`).
179    pub const APPLICATION_DATA_SYNC: Self = Self(30079);
180
181    /// Construct a kind from its 16-bit wire value.
182    #[must_use]
183    pub const fn from_u16(value: u16) -> Self {
184        Self(value)
185    }
186
187    /// Validate a wider integer before narrowing it.
188    ///
189    /// # Errors
190    ///
191    /// Returns [`SoftchatError::InvalidEventKind`] when the value is larger
192    /// than the NIP-01 range.
193    pub fn try_from_u32(value: u32) -> Result<Self, SoftchatError> {
194        let value = u16::try_from(value).map_err(|_| SoftchatError::InvalidEventKind)?;
195        Ok(Self(value))
196    }
197
198    /// Return the NIP-01 wire value.
199    #[must_use]
200    pub const fn as_u16(self) -> u16 {
201        self.0
202    }
203
204    fn as_inner(self) -> Kind {
205        Kind::from_u16(self.0)
206    }
207}
208
209/// One exact, non-empty NIP-01 tag array.
210#[derive(Clone, Eq, Hash, PartialEq)]
211pub struct NostrTag {
212    inner: Tag,
213}
214
215impl NostrTag {
216    /// Retain one raw tag without interpreting or normalizing its elements.
217    ///
218    /// Empty strings and trailing empty elements are significant and remain
219    /// unchanged.
220    ///
221    /// # Errors
222    ///
223    /// Returns [`SoftchatError::InvalidEventTag`] for an empty array.
224    pub fn new<I, S>(values: I) -> Result<Self, SoftchatError>
225    where
226        I: IntoIterator<Item = S>,
227        S: Into<String>,
228    {
229        let inner = Tag::parse(values).map_err(|_| SoftchatError::InvalidEventTag)?;
230        Ok(Self { inner })
231    }
232
233    /// Borrow every raw string in this tag, including trailing empty values.
234    #[must_use]
235    pub fn values(&self) -> &[String] {
236        self.inner.as_slice()
237    }
238
239    fn into_inner(self) -> Tag {
240        self.inner
241    }
242}
243
244impl fmt::Debug for NostrTag {
245    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
246        formatter
247            .debug_tuple("NostrTag")
248            .field(&self.values())
249            .finish()
250    }
251}
252
253/// An explicit, validated NIP-01 event body waiting for a signature.
254///
255/// Drafts deliberately require a caller-supplied timestamp. Hidden wall-clock
256/// reads make protocol fixtures flaky and are not suitable for privacy-aware
257/// wrapper timestamps. Tags retain their exact raw arrays and order.
258#[derive(Clone, Debug, Eq, PartialEq)]
259pub struct NostrEventDraft {
260    created_at: u64,
261    kind: NostrEventKind,
262    tags: Vec<NostrTag>,
263    content: String,
264}
265
266impl NostrEventDraft {
267    /// Construct a draft from explicit protocol values.
268    ///
269    /// # Errors
270    ///
271    /// Returns a portable timestamp or event-size failure.
272    pub fn new(
273        created_at: u64,
274        kind: NostrEventKind,
275        tags: Vec<NostrTag>,
276        content: impl Into<String>,
277    ) -> Result<Self, SoftchatError> {
278        validate_timestamp(created_at)?;
279        let draft = Self {
280            created_at,
281            kind,
282            tags,
283            content: content.into(),
284        };
285        check_draft_size(&draft)?;
286        Ok(draft)
287    }
288
289    /// Return the explicit Unix timestamp in seconds.
290    #[must_use]
291    pub const fn created_at(&self) -> u64 {
292        self.created_at
293    }
294
295    /// Return the event kind.
296    #[must_use]
297    pub const fn kind(&self) -> NostrEventKind {
298        self.kind
299    }
300
301    /// Iterate over exact raw tag arrays without allocating.
302    pub fn tags(&self) -> impl ExactSizeIterator<Item = &[String]> {
303        self.tags.iter().map(NostrTag::values)
304    }
305
306    /// Borrow the event content.
307    #[must_use]
308    pub fn content(&self) -> &str {
309        &self.content
310    }
311
312    pub(crate) fn sign_with_keys(self, keys: &Keys) -> Result<SignedNostrEvent, SoftchatError> {
313        let unsigned = UnsignedEvent::new(
314            keys.public_key(),
315            Timestamp::from(self.created_at),
316            self.kind.as_inner(),
317            self.tags.into_iter().map(NostrTag::into_inner),
318            self.content,
319        );
320        let event = unsigned
321            .sign_with_keys(keys)
322            .map_err(|_| SoftchatError::EventSigningFailed)?;
323        SignedNostrEvent::from_verified_inner(event)
324    }
325}
326
327/// One unsigned NIP-59 rumor with a verified canonical event ID.
328///
329/// A rumor has the normal NIP-01 body and ID but deliberately has no
330/// signature. Authenticity is established only after its enclosing kind-13
331/// seal is verified and its author is bound to the seal signer.
332#[derive(Clone, Eq, PartialEq)]
333pub struct NostrRumor {
334    id: NostrEventId,
335    inner: UnsignedEvent,
336}
337
338impl NostrRumor {
339    /// Create a rumor for one explicit author and event draft.
340    ///
341    /// # Errors
342    ///
343    /// Returns an event-size failure if the resulting unsigned JSON exceeds
344    /// the portable event bound.
345    pub fn new(public_key: NostrPublicKey, draft: NostrEventDraft) -> Result<Self, SoftchatError> {
346        let mut inner = UnsignedEvent::new(
347            *public_key.as_inner(),
348            Timestamp::from(draft.created_at),
349            draft.kind.as_inner(),
350            draft.tags.into_iter().map(NostrTag::into_inner),
351            draft.content,
352        );
353        let id = NostrEventId::from_inner(inner.id());
354        let rumor = Self { id, inner };
355        check_json_size(&rumor.to_json()?)?;
356        Ok(rumor)
357    }
358
359    /// Parse an unsigned event and verify its declared ID.
360    ///
361    /// The exact six rumor fields are required. A signature or unknown
362    /// top-level field is rejected instead of being silently discarded.
363    ///
364    /// # Errors
365    ///
366    /// Returns a stable structural, timestamp, size, or event-ID failure.
367    pub fn from_json(json: &str) -> Result<Self, SoftchatError> {
368        check_json_size(json)?;
369        let wire: WireRumor =
370            serde_json::from_str(json).map_err(|_| SoftchatError::InvalidEventJson)?;
371        if !is_lower_hex_of_length(&wire.id, 64) {
372            return Err(SoftchatError::InvalidEventId);
373        }
374        if !is_lower_hex_of_length(&wire.pubkey, 64) {
375            return Err(SoftchatError::InvalidPublicKey);
376        }
377
378        let declared_id = NostrEventId::from_hex(&wire.id)?;
379        let tags = wire
380            .tags
381            .into_iter()
382            .map(NostrTag::new)
383            .collect::<Result<Vec<_>, _>>()?;
384        let draft = NostrEventDraft::new(
385            wire.created_at,
386            NostrEventKind::try_from_u32(wire.kind)?,
387            tags,
388            wire.content,
389        )?;
390        let rumor = Self::new(NostrPublicKey::from_hex(&wire.pubkey)?, draft)?;
391        if rumor.id != declared_id {
392            return Err(SoftchatError::InvalidEventId);
393        }
394        Ok(rumor)
395    }
396
397    /// Serialize the exact unsigned event as compact JSON.
398    ///
399    /// # Errors
400    ///
401    /// Returns [`SoftchatError::InvalidEventJson`] if serialization fails.
402    pub fn to_json(&self) -> Result<String, SoftchatError> {
403        serde_json::to_string(&self.inner).map_err(|_| SoftchatError::InvalidEventJson)
404    }
405
406    /// Return the recomputed rumor ID.
407    #[must_use]
408    pub const fn id(&self) -> NostrEventId {
409        self.id
410    }
411
412    /// Return the declared rumor author.
413    #[must_use]
414    pub fn public_key(&self) -> NostrPublicKey {
415        NostrPublicKey::from_inner(self.inner.pubkey)
416    }
417
418    /// Return the canonical application timestamp.
419    #[must_use]
420    pub const fn created_at(&self) -> u64 {
421        self.inner.created_at.as_secs()
422    }
423
424    /// Return the rumor kind.
425    #[must_use]
426    pub fn kind(&self) -> NostrEventKind {
427        NostrEventKind::from_u16(self.inner.kind.as_u16())
428    }
429
430    /// Iterate over exact raw rumor tags.
431    pub fn tags(&self) -> impl ExactSizeIterator<Item = &[String]> {
432        self.inner.tags.iter().map(Tag::as_slice)
433    }
434
435    /// Borrow the rumor content.
436    #[must_use]
437    pub fn content(&self) -> &str {
438        &self.inner.content
439    }
440}
441
442impl fmt::Debug for NostrRumor {
443    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
444        formatter
445            .debug_struct("NostrRumor")
446            .field("id", &self.id())
447            .field("public_key", &self.public_key())
448            .field("created_at", &self.created_at())
449            .field("kind", &self.kind())
450            .field("tags", &self.tags().collect::<Vec<_>>())
451            .field("content", &self.content())
452            .finish()
453    }
454}
455
456/// One structurally and cryptographically validated NIP-01 event.
457#[derive(Clone, Eq, PartialEq)]
458pub struct SignedNostrEvent {
459    inner: Event,
460}
461
462impl SignedNostrEvent {
463    /// Parse, bound, and cryptographically verify one event from wire JSON.
464    ///
465    /// Unknown top-level fields are ignored for forward compatibility. The
466    /// seven NIP-01 fields are required and raw tag arrays are retained
467    /// exactly.
468    ///
469    /// # Errors
470    ///
471    /// Returns a stable structural, resource, event-ID, or signature failure.
472    pub fn from_json(json: &str) -> Result<Self, SoftchatError> {
473        let result = Self::from_json_inner(json);
474        if result.is_ok() {
475            diagnostics::record_success(Operation::VerifyEvent, json.len(), json.len());
476        } else {
477            diagnostics::record_rejection(json.len());
478        }
479        result
480    }
481
482    fn from_json_inner(json: &str) -> Result<Self, SoftchatError> {
483        check_json_size(json)?;
484        let wire: WireSignedEvent =
485            serde_json::from_str(json).map_err(|_| SoftchatError::InvalidEventJson)?;
486
487        if !is_lower_hex_of_length(&wire.id, 64) {
488            return Err(SoftchatError::InvalidEventId);
489        }
490        if !is_lower_hex_of_length(&wire.pubkey, 64) {
491            return Err(SoftchatError::InvalidPublicKey);
492        }
493        if !is_lower_hex_of_length(&wire.sig, 128) {
494            return Err(SoftchatError::InvalidEventSignature);
495        }
496
497        let id = NostrEventId::from_hex(&wire.id)?;
498        let public_key = NostrPublicKey::from_hex(&wire.pubkey)?;
499        let kind = NostrEventKind::try_from_u32(wire.kind)?;
500        let signature = NostrEventSignature::from_hex(&wire.sig)?;
501        let tags = wire
502            .tags
503            .into_iter()
504            .map(NostrTag::new)
505            .collect::<Result<Vec<_>, _>>()?;
506
507        Self::new(
508            id,
509            public_key,
510            wire.created_at,
511            kind,
512            tags,
513            wire.content,
514            signature,
515        )
516    }
517
518    /// Construct and verify an event from validated protocol values.
519    ///
520    /// # Errors
521    ///
522    /// Returns a portable timestamp, resource, event-ID, or signature failure.
523    #[allow(clippy::too_many_arguments)]
524    pub fn new(
525        id: NostrEventId,
526        public_key: NostrPublicKey,
527        created_at: u64,
528        kind: NostrEventKind,
529        tags: Vec<NostrTag>,
530        content: String,
531        signature: NostrEventSignature,
532    ) -> Result<Self, SoftchatError> {
533        validate_timestamp(created_at)?;
534
535        let event = Event::new(
536            *id.as_inner(),
537            *public_key.as_inner(),
538            Timestamp::from(created_at),
539            kind.as_inner(),
540            tags.into_iter().map(NostrTag::into_inner),
541            content,
542            *signature.as_inner(),
543        );
544
545        Self::from_verified_inner(event)
546    }
547
548    /// Serialize the seven NIP-01 fields as compact canonical JSON.
549    ///
550    /// # Errors
551    ///
552    /// Returns [`SoftchatError::InvalidEventJson`] if serialization fails.
553    pub fn to_json(&self) -> Result<String, SoftchatError> {
554        serde_json::to_string(&self.inner).map_err(|_| SoftchatError::InvalidEventJson)
555    }
556
557    /// Return the verified event ID.
558    #[must_use]
559    pub const fn id(&self) -> NostrEventId {
560        NostrEventId::from_inner(self.inner.id)
561    }
562
563    /// Return the verified event author's public key.
564    #[must_use]
565    pub fn public_key(&self) -> NostrPublicKey {
566        NostrPublicKey::from_inner(self.inner.pubkey)
567    }
568
569    /// Return the Unix timestamp in seconds.
570    #[must_use]
571    pub const fn created_at(&self) -> u64 {
572        self.inner.created_at.as_secs()
573    }
574
575    /// Return the event kind.
576    #[must_use]
577    pub fn kind(&self) -> NostrEventKind {
578        NostrEventKind::from_u16(self.inner.kind.as_u16())
579    }
580
581    /// Iterate over exact raw tag arrays without allocating.
582    pub fn tags(&self) -> impl ExactSizeIterator<Item = &[String]> {
583        self.inner.tags.iter().map(Tag::as_slice)
584    }
585
586    /// Borrow the event content.
587    #[must_use]
588    pub fn content(&self) -> &str {
589        &self.inner.content
590    }
591
592    /// Return the verified Schnorr signature.
593    #[must_use]
594    pub const fn signature(&self) -> NostrEventSignature {
595        NostrEventSignature::from_inner(self.inner.sig)
596    }
597
598    fn from_verified_inner(event: Event) -> Result<Self, SoftchatError> {
599        match event.verify() {
600            Ok(()) => {}
601            Err(NostrEventError::InvalidId) => return Err(SoftchatError::InvalidEventId),
602            Err(NostrEventError::InvalidSignature) => {
603                return Err(SoftchatError::InvalidEventSignature);
604            }
605            Err(_) => return Err(SoftchatError::InvalidEventJson),
606        }
607
608        let event = Self { inner: event };
609        let canonical = event.to_json()?;
610        check_json_size(&canonical)?;
611        Ok(event)
612    }
613}
614
615impl fmt::Debug for SignedNostrEvent {
616    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
617        formatter
618            .debug_struct("SignedNostrEvent")
619            .field("id", &self.id())
620            .field("public_key", &self.public_key())
621            .field("created_at", &self.created_at())
622            .field("kind", &self.kind())
623            .field("tags", &self.tags().collect::<Vec<_>>())
624            .field("content", &self.content())
625            .field("signature", &self.signature())
626            .finish()
627    }
628}
629
630/// Flat conversion record used only at generated native binding boundaries.
631#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
632#[serde(rename_all = "camelCase", deny_unknown_fields)]
633#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
634pub struct SignedEvent {
635    /// Canonical lowercase event ID.
636    pub id: String,
637    /// Canonical lowercase event-author public key.
638    pub public_key: String,
639    /// Portable Unix timestamp in seconds.
640    pub created_at: i64,
641    /// NIP-01 event kind.
642    pub kind: i32,
643    /// Exact raw tag arrays.
644    pub tags: Vec<Vec<String>>,
645    /// Arbitrary event content.
646    pub content: String,
647    /// Canonical lowercase Schnorr signature.
648    pub signature: String,
649}
650
651/// Flat event-draft record used only at generated native binding boundaries.
652#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
653#[serde(rename_all = "camelCase", deny_unknown_fields)]
654#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
655pub struct EventDraft {
656    /// Explicit portable Unix timestamp in seconds.
657    pub created_at: i64,
658    /// NIP-01 event kind.
659    pub kind: i32,
660    /// Exact raw tag arrays.
661    pub tags: Vec<Vec<String>>,
662    /// Arbitrary event content.
663    pub content: String,
664}
665
666/// Flat unsigned-rumor record used at generated native binding boundaries.
667#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
668#[serde(rename_all = "camelCase", deny_unknown_fields)]
669#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
670pub struct RumorEvent {
671    /// Recomputed canonical rumor ID.
672    pub id: String,
673    /// Canonical rumor-author public key.
674    pub public_key: String,
675    /// Canonical application timestamp.
676    pub created_at: i64,
677    /// NIP-01 event kind.
678    pub kind: i32,
679    /// Exact raw tag arrays.
680    pub tags: Vec<Vec<String>>,
681    /// Rumor content.
682    pub content: String,
683}
684
685impl TryFrom<EventDraft> for NostrEventDraft {
686    type Error = SoftchatError;
687
688    fn try_from(draft: EventDraft) -> Result<Self, Self::Error> {
689        let created_at =
690            u64::try_from(draft.created_at).map_err(|_| SoftchatError::InvalidEventTimestamp)?;
691        let raw_kind = u32::try_from(draft.kind).map_err(|_| SoftchatError::InvalidEventKind)?;
692        let tags = draft
693            .tags
694            .into_iter()
695            .map(NostrTag::new)
696            .collect::<Result<Vec<_>, _>>()?;
697        Self::new(
698            created_at,
699            NostrEventKind::try_from_u32(raw_kind)?,
700            tags,
701            draft.content,
702        )
703    }
704}
705
706impl From<&NostrEventDraft> for EventDraft {
707    fn from(draft: &NostrEventDraft) -> Self {
708        Self {
709            created_at: draft.created_at() as i64,
710            kind: i32::from(draft.kind().as_u16()),
711            tags: draft.tags().map(<[String]>::to_vec).collect(),
712            content: draft.content().to_owned(),
713        }
714    }
715}
716
717impl TryFrom<RumorEvent> for NostrRumor {
718    type Error = SoftchatError;
719
720    fn try_from(rumor: RumorEvent) -> Result<Self, Self::Error> {
721        let created_at =
722            u64::try_from(rumor.created_at).map_err(|_| SoftchatError::InvalidEventTimestamp)?;
723        let raw_kind = u32::try_from(rumor.kind).map_err(|_| SoftchatError::InvalidEventKind)?;
724        let tags = rumor
725            .tags
726            .into_iter()
727            .map(NostrTag::new)
728            .collect::<Result<Vec<_>, _>>()?;
729        let draft = NostrEventDraft::new(
730            created_at,
731            NostrEventKind::try_from_u32(raw_kind)?,
732            tags,
733            rumor.content,
734        )?;
735        let validated = Self::new(NostrPublicKey::from_hex(&rumor.public_key)?, draft)?;
736        if validated.id().to_hex() != rumor.id {
737            return Err(SoftchatError::InvalidEventId);
738        }
739        Ok(validated)
740    }
741}
742
743impl From<&NostrRumor> for RumorEvent {
744    fn from(rumor: &NostrRumor) -> Self {
745        Self {
746            id: rumor.id().to_hex(),
747            public_key: rumor.public_key().to_hex(),
748            created_at: rumor.created_at() as i64,
749            kind: i32::from(rumor.kind().as_u16()),
750            tags: rumor.tags().map(<[String]>::to_vec).collect(),
751            content: rumor.content().to_owned(),
752        }
753    }
754}
755
756impl TryFrom<SignedEvent> for SignedNostrEvent {
757    type Error = SoftchatError;
758
759    fn try_from(event: SignedEvent) -> Result<Self, Self::Error> {
760        if !is_lower_hex_of_length(&event.id, 64) {
761            return Err(SoftchatError::InvalidEventId);
762        }
763        if !is_lower_hex_of_length(&event.public_key, 64) {
764            return Err(SoftchatError::InvalidPublicKey);
765        }
766        if !is_lower_hex_of_length(&event.signature, 128) {
767            return Err(SoftchatError::InvalidEventSignature);
768        }
769
770        let created_at =
771            u64::try_from(event.created_at).map_err(|_| SoftchatError::InvalidEventTimestamp)?;
772        let raw_kind = u32::try_from(event.kind).map_err(|_| SoftchatError::InvalidEventKind)?;
773        let tags = event
774            .tags
775            .into_iter()
776            .map(NostrTag::new)
777            .collect::<Result<Vec<_>, _>>()?;
778
779        Self::new(
780            NostrEventId::from_hex(&event.id)?,
781            NostrPublicKey::from_hex(&event.public_key)?,
782            created_at,
783            NostrEventKind::try_from_u32(raw_kind)?,
784            tags,
785            event.content,
786            NostrEventSignature::from_hex(&event.signature)?,
787        )
788    }
789}
790
791impl From<&SignedNostrEvent> for SignedEvent {
792    fn from(event: &SignedNostrEvent) -> Self {
793        Self {
794            id: event.id().to_hex(),
795            public_key: event.public_key().to_hex(),
796            created_at: event.created_at() as i64,
797            kind: i32::from(event.kind().as_u16()),
798            tags: event.tags().map(<[String]>::to_vec).collect(),
799            content: event.content().to_owned(),
800            signature: event.signature().to_hex(),
801        }
802    }
803}
804
805#[derive(Debug, Deserialize)]
806struct WireSignedEvent {
807    id: String,
808    pubkey: String,
809    created_at: u64,
810    kind: u32,
811    tags: Vec<Vec<String>>,
812    content: String,
813    sig: String,
814}
815
816#[derive(Debug, Deserialize)]
817#[serde(deny_unknown_fields)]
818struct WireRumor {
819    id: String,
820    pubkey: String,
821    created_at: u64,
822    kind: u32,
823    tags: Vec<Vec<String>>,
824    content: String,
825}
826
827/// Validate and canonicalize one event ID for generated bindings.
828#[cfg_attr(feature = "native-bindings", uniffi::export)]
829pub fn validate_nostr_event_id(event_id_hex: String) -> Result<String, SoftchatError> {
830    NostrEventId::from_hex(&event_id_hex).map(NostrEventId::to_hex)
831}
832
833/// Validate and canonicalize one Schnorr signature for generated bindings.
834#[cfg_attr(feature = "native-bindings", uniffi::export)]
835pub fn validate_nostr_event_signature(signature_hex: String) -> Result<String, SoftchatError> {
836    NostrEventSignature::from_hex(&signature_hex).map(NostrEventSignature::to_hex)
837}
838
839/// Validate one signed 32-bit event kind for generated bindings.
840#[cfg_attr(feature = "native-bindings", uniffi::export)]
841pub fn validate_nostr_event_kind(kind: i32) -> Result<i32, SoftchatError> {
842    let raw = u32::try_from(kind).map_err(|_| SoftchatError::InvalidEventKind)?;
843    Ok(i32::from(NostrEventKind::try_from_u32(raw)?.as_u16()))
844}
845
846/// Validate one exact raw NIP-01 tag for generated bindings.
847#[cfg_attr(feature = "native-bindings", uniffi::export)]
848pub fn validate_nostr_tag(values: Vec<String>) -> Result<Vec<String>, SoftchatError> {
849    NostrTag::new(values).map(|tag| tag.values().to_vec())
850}
851
852/// Validate one explicit event draft for generated bindings.
853#[cfg_attr(feature = "native-bindings", uniffi::export)]
854pub fn validate_nostr_event_draft(draft: EventDraft) -> Result<EventDraft, SoftchatError> {
855    NostrEventDraft::try_from(draft).map(|draft| EventDraft::from(&draft))
856}
857
858/// Parse and verify one unsigned NIP-59 rumor for generated bindings.
859#[cfg_attr(feature = "native-bindings", uniffi::export)]
860pub fn parse_nostr_rumor_json(json: String) -> Result<RumorEvent, SoftchatError> {
861    NostrRumor::from_json(&json).map(|rumor| RumorEvent::from(&rumor))
862}
863
864/// Verify one binding-owned unsigned NIP-59 rumor.
865#[cfg_attr(feature = "native-bindings", uniffi::export)]
866pub fn validate_nostr_rumor(rumor: RumorEvent) -> Result<RumorEvent, SoftchatError> {
867    NostrRumor::try_from(rumor).map(|rumor| RumorEvent::from(&rumor))
868}
869
870/// Serialize one verified rumor as compact unsigned-event JSON.
871#[cfg_attr(feature = "native-bindings", uniffi::export)]
872pub fn nostr_rumor_json(rumor: RumorEvent) -> Result<String, SoftchatError> {
873    NostrRumor::try_from(rumor)?.to_json()
874}
875
876/// Parse and cryptographically verify one wire event for generated bindings.
877#[cfg_attr(feature = "native-bindings", uniffi::export)]
878pub fn parse_signed_nostr_event_json(json: String) -> Result<SignedEvent, SoftchatError> {
879    SignedNostrEvent::from_json(&json).map(|event| SignedEvent::from(&event))
880}
881
882/// Cryptographically verify one binding-owned event value.
883#[cfg_attr(feature = "native-bindings", uniffi::export)]
884pub fn validate_signed_nostr_event(event: SignedEvent) -> Result<SignedEvent, SoftchatError> {
885    SignedNostrEvent::try_from(event).map(|event| SignedEvent::from(&event))
886}
887
888/// Serialize one verified binding-owned event as compact NIP-01 JSON.
889#[cfg_attr(feature = "native-bindings", uniffi::export)]
890pub fn signed_nostr_event_json(event: SignedEvent) -> Result<String, SoftchatError> {
891    SignedNostrEvent::try_from(event)?.to_json()
892}
893
894fn check_json_size(json: &str) -> Result<(), SoftchatError> {
895    if json.len() > MAX_NOSTR_EVENT_JSON_BYTES {
896        return Err(SoftchatError::EventTooLarge);
897    }
898    Ok(())
899}
900
901fn check_draft_size(draft: &NostrEventDraft) -> Result<(), SoftchatError> {
902    #[derive(Serialize)]
903    struct SizeProbe<'a> {
904        id: &'static str,
905        pubkey: &'static str,
906        created_at: u64,
907        kind: u16,
908        tags: Vec<&'a [String]>,
909        content: &'a str,
910        sig: &'static str,
911    }
912
913    const EVENT_ID_PLACEHOLDER: &str =
914        "0000000000000000000000000000000000000000000000000000000000000000";
915    const SIGNATURE_PLACEHOLDER: &str = concat!(
916        "0000000000000000000000000000000000000000000000000000000000000000",
917        "0000000000000000000000000000000000000000000000000000000000000000"
918    );
919
920    let probe = SizeProbe {
921        id: EVENT_ID_PLACEHOLDER,
922        pubkey: EVENT_ID_PLACEHOLDER,
923        created_at: draft.created_at,
924        kind: draft.kind.as_u16(),
925        tags: draft.tags().collect(),
926        content: draft.content(),
927        sig: SIGNATURE_PLACEHOLDER,
928    };
929    let json = serde_json::to_string(&probe).map_err(|_| SoftchatError::InvalidEventJson)?;
930    check_json_size(&json)
931}
932
933fn validate_timestamp(created_at: u64) -> Result<(), SoftchatError> {
934    if created_at > MAX_PORTABLE_TIMESTAMP_SECONDS {
935        return Err(SoftchatError::InvalidEventTimestamp);
936    }
937    Ok(())
938}
939
940fn is_hex_of_length(value: &str, expected: usize) -> bool {
941    value.len() == expected && value.bytes().all(|byte| byte.is_ascii_hexdigit())
942}
943
944fn is_lower_hex_of_length(value: &str, expected: usize) -> bool {
945    value.len() == expected
946        && value
947            .bytes()
948            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
949}
950
951#[cfg(test)]
952mod tests {
953    use super::*;
954    use crate::LocalIdentity;
955
956    const VALID_EVENT: &str =
957        include_str!("../../../fixtures/nostr/nip01/signed-event-with-empty-tag-fields.json");
958    const ALICE_SECRET: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
959    const BOB_PUBLIC_KEY: &str = "4ddeb9109a8cd29ba279a637f5ec344f2479ee07df1f4043f3fe26d8948cfef9";
960
961    #[test]
962    fn parses_verifies_and_preserves_exact_raw_tags() -> Result<(), SoftchatError> {
963        let event = SignedNostrEvent::from_json(VALID_EVENT.trim())?;
964
965        assert_eq!(
966            event.id().to_hex(),
967            "f55c30722f056e330d8a7a6a9ba1522f7522c0f1ced1c93d78ea833c78a3d6ec"
968        );
969        assert_eq!(event.kind().as_u16(), 3);
970        assert_eq!(
971            event.tags().next(),
972            Some(
973                [
974                    "p",
975                    "4ddeb9109a8cd29ba279a637f5ec344f2479ee07df1f4043f3fe26d8948cfef9",
976                    "",
977                    "",
978                ]
979                .map(String::from)
980                .as_slice()
981            )
982        );
983
984        let canonical = event.to_json()?;
985        let reparsed = SignedNostrEvent::from_json(&canonical)?;
986        assert_eq!(reparsed, event);
987        assert_eq!(canonical, VALID_EVENT.trim());
988        Ok(())
989    }
990
991    #[test]
992    fn reports_id_before_signature_tampering() {
993        let tampered = VALID_EVENT.replace("\"content\":\"\"", "\"content\":\"changed\"");
994        assert!(matches!(
995            SignedNostrEvent::from_json(&tampered),
996            Err(SoftchatError::InvalidEventId)
997        ));
998    }
999
1000    #[test]
1001    fn rejects_invalid_signature_after_valid_id() {
1002        let tampered = VALID_EVENT.replacen("\"sig\":\"5", "\"sig\":\"4", 1);
1003        assert!(matches!(
1004            SignedNostrEvent::from_json(&tampered),
1005            Err(SoftchatError::InvalidEventSignature)
1006        ));
1007    }
1008
1009    #[test]
1010    fn rejects_noncanonical_wire_hex_and_invalid_shapes() {
1011        let uppercase_id = VALID_EVENT.replacen("\"id\":\"f", "\"id\":\"F", 1);
1012        assert!(matches!(
1013            SignedNostrEvent::from_json(&uppercase_id),
1014            Err(SoftchatError::InvalidEventId)
1015        ));
1016
1017        let empty_tag = VALID_EVENT.replacen(r#"[["p","4ddeb"#, r#"[[] ,["p","4ddeb"#, 1);
1018        assert!(matches!(
1019            SignedNostrEvent::from_json(&empty_tag),
1020            Err(SoftchatError::InvalidEventTag)
1021        ));
1022
1023        let invalid_kind = VALID_EVENT.replacen("\"kind\":3", "\"kind\":65536", 1);
1024        assert!(matches!(
1025            SignedNostrEvent::from_json(&invalid_kind),
1026            Err(SoftchatError::InvalidEventKind)
1027        ));
1028    }
1029
1030    #[test]
1031    fn rejects_unportable_timestamps_and_oversized_json() {
1032        let invalid_timestamp = VALID_EVENT.replacen(
1033            "\"created_at\":1698412975",
1034            "\"created_at\":9007199254740992",
1035            1,
1036        );
1037        assert!(matches!(
1038            SignedNostrEvent::from_json(&invalid_timestamp),
1039            Err(SoftchatError::InvalidEventTimestamp)
1040        ));
1041
1042        let oversized = " ".repeat(MAX_NOSTR_EVENT_JSON_BYTES + 1);
1043        assert!(matches!(
1044            SignedNostrEvent::from_json(&oversized),
1045            Err(SoftchatError::EventTooLarge)
1046        ));
1047    }
1048
1049    #[test]
1050    fn scalar_binding_validators_canonicalize_and_reject() -> Result<(), SoftchatError> {
1051        let id = validate_nostr_event_id("AB".repeat(32))?;
1052        assert_eq!(id, "ab".repeat(32));
1053        assert!(matches!(
1054            validate_nostr_event_kind(-1),
1055            Err(SoftchatError::InvalidEventKind)
1056        ));
1057        assert!(matches!(
1058            validate_nostr_tag(Vec::new()),
1059            Err(SoftchatError::InvalidEventTag)
1060        ));
1061
1062        let mut decoded_wire = parse_signed_nostr_event_json(VALID_EVENT.trim().to_owned())?;
1063        decoded_wire.id.make_ascii_uppercase();
1064        assert!(matches!(
1065            validate_signed_nostr_event(decoded_wire),
1066            Err(SoftchatError::InvalidEventId)
1067        ));
1068        Ok(())
1069    }
1070
1071    #[test]
1072    fn signs_explicit_drafts_without_normalizing_tags() -> Result<(), SoftchatError> {
1073        let identity = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
1074        let draft = NostrEventDraft::new(
1075            1_700_000_000,
1076            NostrEventKind::SHORT_TEXT_NOTE,
1077            vec![NostrTag::new([
1078                "p".to_owned(),
1079                BOB_PUBLIC_KEY.to_owned(),
1080                String::new(),
1081                String::new(),
1082            ])?],
1083            "A signed protocol event",
1084        )?;
1085
1086        let signed = identity.sign_event(draft)?;
1087        assert_eq!(signed.public_key(), identity.public_key());
1088        assert_eq!(signed.created_at(), 1_700_000_000);
1089        assert_eq!(signed.kind(), NostrEventKind::SHORT_TEXT_NOTE);
1090        assert_eq!(
1091            signed.tags().next(),
1092            Some(["p", BOB_PUBLIC_KEY, "", ""].map(String::from).as_slice())
1093        );
1094
1095        let reparsed = SignedNostrEvent::from_json(&signed.to_json()?)?;
1096        assert_eq!(reparsed, signed);
1097        Ok(())
1098    }
1099
1100    #[test]
1101    fn validates_drafts_before_signing() {
1102        assert!(matches!(
1103            NostrEventDraft::new(
1104                MAX_PORTABLE_TIMESTAMP_SECONDS + 1,
1105                NostrEventKind::SHORT_TEXT_NOTE,
1106                Vec::new(),
1107                "invalid",
1108            ),
1109            Err(SoftchatError::InvalidEventTimestamp)
1110        ));
1111        assert!(matches!(
1112            NostrEventDraft::new(
1113                1_700_000_000,
1114                NostrEventKind::SHORT_TEXT_NOTE,
1115                Vec::new(),
1116                "x".repeat(MAX_NOSTR_EVENT_JSON_BYTES),
1117            ),
1118            Err(SoftchatError::EventTooLarge)
1119        ));
1120    }
1121}