Skip to main content

softchat/
nip19.rs

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