Skip to main content

softchat/
nip19.rs

1//! Bounded NIP-19 display identifiers used by Softchat.
2
3use bech32::{Bech32, Hrp};
4
5use crate::{NostrEventId, NostrEventKind, NostrPublicKey, SoftchatError};
6
7/// Maximum accepted encoded identifier length.
8///
9/// NIP-19 recommends a 5,000-character limit. Softchat uses the same bound.
10pub const MAX_NIP19_IDENTIFIER_CHARS: usize = 5_000;
11
12/// Maximum relay hints retained in one shareable identifier.
13pub const MAX_NIP19_RELAYS: usize = 8;
14
15const MAX_RELAY_HINT_BYTES: usize = 255;
16const TLV_SPECIAL: u8 = 0;
17const TLV_RELAY: u8 = 1;
18const TLV_AUTHOR: u8 = 2;
19const TLV_KIND: u8 = 3;
20
21/// Supported NIP-19 identifier family.
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
24pub enum Nip19IdentifierKind {
25    /// Bare public key (`npub`).
26    PublicKey,
27    /// Bare event ID (`note`).
28    EventId,
29    /// Public key plus relay hints (`nprofile`).
30    Profile,
31    /// Event ID plus optional author, kind, and relay hints (`nevent`).
32    Event,
33}
34
35/// One unknown NIP-19 TLV retained for forward-compatible re-encoding.
36#[derive(Clone, Debug, Eq, PartialEq)]
37#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
38pub struct Nip19Tlv {
39    /// Unknown one-byte TLV type.
40    pub type_id: u32,
41    /// Exact TLV value bytes.
42    pub value: Vec<u8>,
43}
44
45/// A bounded, parsed NIP-19 value.
46#[derive(Clone, Debug, Eq, PartialEq)]
47pub struct Nip19Identifier {
48    kind: Nip19IdentifierKind,
49    primary_hex: String,
50    relays: Vec<String>,
51    author_hex: Option<String>,
52    event_kind: Option<NostrEventKind>,
53    unknown_tlvs: Vec<Nip19Tlv>,
54}
55
56impl Nip19Identifier {
57    /// Parse one supported NIP-19 identifier.
58    ///
59    /// Secret keys and addressable-event coordinates are deliberately outside
60    /// this display-only Softchat boundary. Unknown TLVs are retained.
61    ///
62    /// # Errors
63    ///
64    /// Returns [`SoftchatError::InvalidNip19Identifier`] for malformed,
65    /// mixed-case, unsupported, oversized, duplicate, or invalid values.
66    pub fn parse(encoded: &str) -> Result<Self, SoftchatError> {
67        validate_encoded_shape(encoded)?;
68        let (hrp, data) =
69            bech32::decode(encoded).map_err(|_| SoftchatError::InvalidNip19Identifier)?;
70        match hrp.as_str() {
71            "npub" => parse_bare(Nip19IdentifierKind::PublicKey, &data),
72            "note" => parse_bare(Nip19IdentifierKind::EventId, &data),
73            "nprofile" => parse_tlv(Nip19IdentifierKind::Profile, &data),
74            "nevent" => parse_tlv(Nip19IdentifierKind::Event, &data),
75            _ => Err(SoftchatError::InvalidNip19Identifier),
76        }
77    }
78
79    /// Encode the canonical lowercase representation.
80    ///
81    /// # Errors
82    ///
83    /// Returns [`SoftchatError::InvalidNip19Identifier`] if retained fields no
84    /// longer satisfy the bounded Softchat profile.
85    pub fn encode(&self) -> Result<String, SoftchatError> {
86        let (hrp, bytes) = match self.kind {
87            Nip19IdentifierKind::PublicKey => {
88                let key = NostrPublicKey::from_hex(&self.primary_hex)
89                    .map_err(|_| SoftchatError::InvalidNip19Identifier)?;
90                ("npub", key.as_inner().to_bytes().to_vec())
91            }
92            Nip19IdentifierKind::EventId => {
93                let id = NostrEventId::from_hex(&self.primary_hex)
94                    .map_err(|_| SoftchatError::InvalidNip19Identifier)?;
95                ("note", id.as_inner().as_bytes().to_vec())
96            }
97            Nip19IdentifierKind::Profile | Nip19IdentifierKind::Event => {
98                let bytes = self.encode_tlvs()?;
99                let hrp = if self.kind == Nip19IdentifierKind::Profile {
100                    "nprofile"
101                } else {
102                    "nevent"
103                };
104                (hrp, bytes)
105            }
106        };
107        let hrp = Hrp::parse(hrp).map_err(|_| SoftchatError::InvalidNip19Identifier)?;
108        let encoded = bech32::encode::<Bech32>(hrp, &bytes)
109            .map_err(|_| SoftchatError::InvalidNip19Identifier)?;
110        validate_encoded_shape(&encoded)?;
111        Ok(encoded)
112    }
113
114    /// Identifier family.
115    #[must_use]
116    pub const fn kind(&self) -> Nip19IdentifierKind {
117        self.kind
118    }
119
120    /// Canonical public key or event ID hexadecimal value.
121    #[must_use]
122    pub fn primary_hex(&self) -> &str {
123        &self.primary_hex
124    }
125
126    /// Ordered, validated relay hints.
127    #[must_use]
128    pub fn relays(&self) -> &[String] {
129        &self.relays
130    }
131
132    /// Optional event author for `nevent`.
133    #[must_use]
134    pub fn author_hex(&self) -> Option<&str> {
135        self.author_hex.as_deref()
136    }
137
138    /// Optional event kind for `nevent`.
139    #[must_use]
140    pub const fn event_kind(&self) -> Option<NostrEventKind> {
141        self.event_kind
142    }
143
144    /// Unknown TLVs retained without interpretation.
145    #[must_use]
146    pub fn unknown_tlvs(&self) -> &[Nip19Tlv] {
147        &self.unknown_tlvs
148    }
149
150    fn encode_tlvs(&self) -> Result<Vec<u8>, SoftchatError> {
151        let mut output = Vec::new();
152        let special = match self.kind {
153            Nip19IdentifierKind::Profile => NostrPublicKey::from_hex(&self.primary_hex)
154                .map(|key| key.as_inner().to_bytes().to_vec()),
155            Nip19IdentifierKind::Event => NostrEventId::from_hex(&self.primary_hex)
156                .map(|id| id.as_inner().as_bytes().to_vec()),
157            Nip19IdentifierKind::PublicKey | Nip19IdentifierKind::EventId => {
158                return Err(SoftchatError::InvalidNip19Identifier);
159            }
160        }
161        .map_err(|_| SoftchatError::InvalidNip19Identifier)?;
162        push_tlv(&mut output, TLV_SPECIAL, &special)?;
163        validate_relays(&self.relays)?;
164        for relay in &self.relays {
165            push_tlv(&mut output, TLV_RELAY, relay.as_bytes())?;
166        }
167        if self.kind == Nip19IdentifierKind::Profile
168            && (self.author_hex.is_some() || self.event_kind.is_some())
169        {
170            return Err(SoftchatError::InvalidNip19Identifier);
171        }
172        if let Some(author) = &self.author_hex {
173            let author = NostrPublicKey::from_hex(author)
174                .map_err(|_| SoftchatError::InvalidNip19Identifier)?;
175            push_tlv(&mut output, TLV_AUTHOR, &author.as_inner().to_bytes())?;
176        }
177        if let Some(kind) = self.event_kind {
178            push_tlv(
179                &mut output,
180                TLV_KIND,
181                &u32::from(kind.as_u16()).to_be_bytes(),
182            )?;
183        }
184        for unknown in &self.unknown_tlvs {
185            let type_id =
186                u8::try_from(unknown.type_id).map_err(|_| SoftchatError::InvalidNip19Identifier)?;
187            if matches!(type_id, TLV_SPECIAL | TLV_RELAY | TLV_AUTHOR | TLV_KIND) {
188                return Err(SoftchatError::InvalidNip19Identifier);
189            }
190            push_tlv(&mut output, type_id, &unknown.value)?;
191        }
192        Ok(output)
193    }
194}
195
196/// Flat NIP-19 value for generated native bindings.
197#[derive(Clone, Debug, Eq, PartialEq)]
198#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
199pub struct Nip19Value {
200    /// Identifier family.
201    pub kind: Nip19IdentifierKind,
202    /// Canonical public key or event ID.
203    pub primary_hex: String,
204    /// Ordered relay hints.
205    pub relays: Vec<String>,
206    /// Optional event author.
207    pub author_hex: Option<String>,
208    /// Optional event kind.
209    pub event_kind: Option<u32>,
210    /// Unknown retained TLVs.
211    pub unknown_tlvs: Vec<Nip19Tlv>,
212}
213
214impl From<&Nip19Identifier> for Nip19Value {
215    fn from(value: &Nip19Identifier) -> Self {
216        Self {
217            kind: value.kind,
218            primary_hex: value.primary_hex.clone(),
219            relays: value.relays.clone(),
220            author_hex: value.author_hex.clone(),
221            event_kind: value.event_kind.map(|kind| u32::from(kind.as_u16())),
222            unknown_tlvs: value.unknown_tlvs.clone(),
223        }
224    }
225}
226
227impl TryFrom<Nip19Value> for Nip19Identifier {
228    type Error = SoftchatError;
229
230    fn try_from(value: Nip19Value) -> Result<Self, Self::Error> {
231        let event_kind = value
232            .event_kind
233            .map(NostrEventKind::try_from_u32)
234            .transpose()
235            .map_err(|_| SoftchatError::InvalidNip19Identifier)?;
236        let value = Self {
237            kind: value.kind,
238            primary_hex: value.primary_hex,
239            relays: value.relays,
240            author_hex: value.author_hex,
241            event_kind,
242            unknown_tlvs: value.unknown_tlvs,
243        };
244        value.encode()?;
245        Ok(value)
246    }
247}
248
249/// Parse one supported NIP-19 value for generated native bindings.
250#[cfg_attr(feature = "native-bindings", uniffi::export)]
251pub fn parse_nip19_identifier(encoded: String) -> Result<Nip19Value, SoftchatError> {
252    Nip19Identifier::parse(&encoded).map(|value| Nip19Value::from(&value))
253}
254
255/// Encode one validated NIP-19 value for generated native bindings.
256#[cfg_attr(feature = "native-bindings", uniffi::export)]
257pub fn encode_nip19_identifier(value: Nip19Value) -> Result<String, SoftchatError> {
258    Nip19Identifier::try_from(value)?.encode()
259}
260
261fn validate_encoded_shape(encoded: &str) -> Result<(), SoftchatError> {
262    if encoded.is_empty() || encoded.len() > MAX_NIP19_IDENTIFIER_CHARS {
263        return Err(SoftchatError::InvalidNip19Identifier);
264    }
265    let has_lower = encoded.bytes().any(|byte| byte.is_ascii_lowercase());
266    let has_upper = encoded.bytes().any(|byte| byte.is_ascii_uppercase());
267    if has_lower && has_upper {
268        return Err(SoftchatError::InvalidNip19Identifier);
269    }
270    Ok(())
271}
272
273fn parse_bare(kind: Nip19IdentifierKind, data: &[u8]) -> Result<Nip19Identifier, SoftchatError> {
274    if data.len() != 32 {
275        return Err(SoftchatError::InvalidNip19Identifier);
276    }
277    let primary_hex = match kind {
278        Nip19IdentifierKind::PublicKey => {
279            NostrPublicKey::from_hex(&hex::encode(data)).map(|key| key.to_hex())
280        }
281        Nip19IdentifierKind::EventId => {
282            NostrEventId::from_hex(&hex::encode(data)).map(NostrEventId::to_hex)
283        }
284        Nip19IdentifierKind::Profile | Nip19IdentifierKind::Event => {
285            return Err(SoftchatError::InvalidNip19Identifier);
286        }
287    }
288    .map_err(|_| SoftchatError::InvalidNip19Identifier)?;
289    Ok(Nip19Identifier {
290        kind,
291        primary_hex,
292        relays: Vec::new(),
293        author_hex: None,
294        event_kind: None,
295        unknown_tlvs: Vec::new(),
296    })
297}
298
299fn parse_tlv(kind: Nip19IdentifierKind, data: &[u8]) -> Result<Nip19Identifier, SoftchatError> {
300    let mut cursor = data;
301    let mut primary_hex = None;
302    let mut relays = Vec::new();
303    let mut author_hex = None;
304    let mut event_kind = None;
305    let mut unknown_tlvs = Vec::new();
306    while !cursor.is_empty() {
307        let type_id = *cursor
308            .first()
309            .ok_or(SoftchatError::InvalidNip19Identifier)?;
310        let length = usize::from(*cursor.get(1).ok_or(SoftchatError::InvalidNip19Identifier)?);
311        let value = cursor
312            .get(2..2 + length)
313            .ok_or(SoftchatError::InvalidNip19Identifier)?;
314        cursor = cursor
315            .get(2 + length..)
316            .ok_or(SoftchatError::InvalidNip19Identifier)?;
317        match type_id {
318            TLV_SPECIAL => {
319                if primary_hex.is_some() || value.len() != 32 {
320                    return Err(SoftchatError::InvalidNip19Identifier);
321                }
322                let hex = hex::encode(value);
323                primary_hex = Some(match kind {
324                    Nip19IdentifierKind::Profile => NostrPublicKey::from_hex(&hex)
325                        .map(|key| key.to_hex())
326                        .map_err(|_| SoftchatError::InvalidNip19Identifier)?,
327                    Nip19IdentifierKind::Event => NostrEventId::from_hex(&hex)
328                        .map(NostrEventId::to_hex)
329                        .map_err(|_| SoftchatError::InvalidNip19Identifier)?,
330                    Nip19IdentifierKind::PublicKey | Nip19IdentifierKind::EventId => {
331                        return Err(SoftchatError::InvalidNip19Identifier);
332                    }
333                });
334            }
335            TLV_RELAY => {
336                let relay = std::str::from_utf8(value)
337                    .map_err(|_| SoftchatError::InvalidNip19Identifier)?
338                    .to_owned();
339                validate_relay(&relay)?;
340                relays.push(relay);
341                if relays.len() > MAX_NIP19_RELAYS {
342                    return Err(SoftchatError::InvalidNip19Identifier);
343                }
344            }
345            TLV_AUTHOR if kind == Nip19IdentifierKind::Event => {
346                if author_hex.is_some() || value.len() != 32 {
347                    return Err(SoftchatError::InvalidNip19Identifier);
348                }
349                author_hex = Some(
350                    NostrPublicKey::from_hex(&hex::encode(value))
351                        .map(|key| key.to_hex())
352                        .map_err(|_| SoftchatError::InvalidNip19Identifier)?,
353                );
354            }
355            TLV_KIND if kind == Nip19IdentifierKind::Event => {
356                if event_kind.is_some() || value.len() != 4 {
357                    return Err(SoftchatError::InvalidNip19Identifier);
358                }
359                let bytes: [u8; 4] = value
360                    .try_into()
361                    .map_err(|_| SoftchatError::InvalidNip19Identifier)?;
362                event_kind = Some(
363                    NostrEventKind::try_from_u32(u32::from_be_bytes(bytes))
364                        .map_err(|_| SoftchatError::InvalidNip19Identifier)?,
365                );
366            }
367            _ => unknown_tlvs.push(Nip19Tlv {
368                type_id: u32::from(type_id),
369                value: value.to_vec(),
370            }),
371        }
372    }
373    validate_relays(&relays)?;
374    Ok(Nip19Identifier {
375        kind,
376        primary_hex: primary_hex.ok_or(SoftchatError::InvalidNip19Identifier)?,
377        relays,
378        author_hex,
379        event_kind,
380        unknown_tlvs,
381    })
382}
383
384fn validate_relays(relays: &[String]) -> Result<(), SoftchatError> {
385    if relays.len() > MAX_NIP19_RELAYS {
386        return Err(SoftchatError::InvalidNip19Identifier);
387    }
388    for relay in relays {
389        validate_relay(relay)?;
390    }
391    Ok(())
392}
393
394fn validate_relay(relay: &str) -> Result<(), SoftchatError> {
395    if relay.is_empty()
396        || relay.len() > MAX_RELAY_HINT_BYTES
397        || (!relay.starts_with("ws://") && !relay.starts_with("wss://"))
398        || nostr::RelayUrl::parse(relay).is_err()
399    {
400        return Err(SoftchatError::InvalidNip19Identifier);
401    }
402    Ok(())
403}
404
405fn push_tlv(output: &mut Vec<u8>, type_id: u8, value: &[u8]) -> Result<(), SoftchatError> {
406    let length = u8::try_from(value.len()).map_err(|_| SoftchatError::InvalidNip19Identifier)?;
407    output.push(type_id);
408    output.push(length);
409    output.extend_from_slice(value);
410    Ok(())
411}
412
413#[cfg(test)]
414mod tests {
415    use serde::Deserialize;
416
417    use super::*;
418
419    #[derive(Deserialize)]
420    #[serde(rename_all = "camelCase")]
421    struct BareExample {
422        encoded: String,
423        public_key: String,
424    }
425
426    #[derive(Deserialize)]
427    #[serde(rename_all = "camelCase")]
428    struct ProfileExample {
429        encoded: String,
430        public_key: String,
431        relays: Vec<String>,
432    }
433
434    #[derive(Deserialize)]
435    struct Examples {
436        npub: BareExample,
437        nprofile: ProfileExample,
438    }
439
440    #[derive(Deserialize)]
441    struct Fixture {
442        examples: Examples,
443    }
444
445    fn official_fixture() -> Result<Fixture, serde_json::Error> {
446        serde_json::from_str(include_str!(
447            "../../../fixtures/nostr/nip19/nip19-official.json"
448        ))
449    }
450
451    #[test]
452    fn parses_official_public_key_and_profile_examples() -> Result<(), SoftchatError> {
453        let fixture = official_fixture().map_err(|_| SoftchatError::InvalidNip19Identifier)?;
454        let public_key = Nip19Identifier::parse(&fixture.examples.npub.encoded)?;
455        assert_eq!(public_key.kind(), Nip19IdentifierKind::PublicKey);
456        assert_eq!(public_key.primary_hex(), fixture.examples.npub.public_key);
457        assert_eq!(public_key.encode()?, fixture.examples.npub.encoded);
458
459        let profile = Nip19Identifier::parse(&fixture.examples.nprofile.encoded)?;
460        assert_eq!(profile.kind(), Nip19IdentifierKind::Profile);
461        assert_eq!(profile.primary_hex(), fixture.examples.nprofile.public_key);
462        assert_eq!(profile.relays(), fixture.examples.nprofile.relays);
463        assert_eq!(profile.encode()?, fixture.examples.nprofile.encoded);
464        Ok(())
465    }
466
467    #[test]
468    fn round_trips_nevent_unknown_tlv_and_rejects_mixed_case() -> Result<(), SoftchatError> {
469        let value = Nip19Value {
470            kind: Nip19IdentifierKind::Event,
471            primary_hex: "44900586091b284416a0c001f677f9c49f7639a55c3f1e2ec130a8e1a7998e1b"
472                .to_owned(),
473            relays: vec!["wss://relay.example".to_owned()],
474            author_hex: Some(
475                "918e2da906df4ccd12c8ac672d8335add131a4cf9d27ce42b3bb3625755f0788".to_owned(),
476            ),
477            event_kind: Some(14),
478            unknown_tlvs: vec![Nip19Tlv {
479                type_id: 99,
480                value: vec![0, 1, 2],
481            }],
482        };
483        let encoded = encode_nip19_identifier(value.clone())?;
484        assert_eq!(parse_nip19_identifier(encoded.clone())?, value);
485
486        let mut mixed = encoded;
487        mixed.replace_range(0..1, "N");
488        assert!(matches!(
489            Nip19Identifier::parse(&mixed),
490            Err(SoftchatError::InvalidNip19Identifier)
491        ));
492        Ok(())
493    }
494}