Skip to main content

softchat/
event.rs

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