Skip to main content

nula_core/nips/
nip39.rs

1//! [NIP-39] External Identities in Profiles.
2//!
3//! NIP-39 lets a Nostr user attest control over an account on another
4//! platform (GitHub, Twitter, Mastodon, Telegram, …) via an `i` tag:
5//!
6//! ```jsonc
7//! ["i", "<platform>:<identity>", "<proof>"]
8//! ```
9//!
10//! Spec 32§"Clients SHOULD process any `i` tags with more than 2
11//! values for future extensibility" forces a *forward-compatible*
12//! decoder: an unknown platform name MUST round-trip through this
13//! crate without erroring out, otherwise a client running an older
14//! `nula-core` would silently drop new identities authored by a
15//! newer client. This module models the openness via
16//! [`ExternalPlatform::Other`].
17//!
18//! # Authoring vs reading
19//!
20//! - Author with [`Tag::external_identity`] (added by this module on
21//!   the [`Tag`] type) which canonicalises the
22//!   `<platform>:<identity>` join.
23//! - Read with [`identities_from_tags`] which yields every
24//!   well-formed `i` tag while ignoring NIP-73 external content
25//!   identifiers (the same `i` head is shared but those carry only
26//!   one value vs NIP-39's two-or-more).
27//!
28//! # Differentiation from upstream
29//!
30//! `rust-nostr/nostr@master` closes [`ExternalPlatform`] as an enum
31//! that returns `Err(InvalidIdentity)` for any platform it doesn't
32//! know. That is incorrect per the NIP-39 forward-compat rule. We
33//! keep the well-known variants as constants but accept arbitrary
34//! platform names through [`ExternalPlatform::Other`] and let
35//! callers match on the variant when they care.
36//!
37//! [NIP-39]: https://github.com/nostr-protocol/nips/blob/master/39.md
38
39use thiserror::Error;
40
41use crate::event::{Alphabet, SingleLetterTag, Tag, TagKind, Tags};
42
43/// External identity provider.
44///
45/// Well-known platforms have dedicated variants so pattern-matching
46/// is concise; unknown ones are preserved verbatim through
47/// [`Self::Other`] to honour NIP-39's "SHOULD process any i tags
48/// with more than 2 values for future extensibility".
49#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
50#[non_exhaustive]
51pub enum ExternalPlatform {
52    /// `github` — proof is a Gist id under the same username.
53    GitHub,
54    /// `twitter` — proof is a tweet id under the same handle.
55    Twitter,
56    /// `mastodon` — identity carries `<instance>/@<username>`; proof
57    /// is a status id on that instance.
58    Mastodon,
59    /// `telegram` — proof is `<channel>/<message-id>`.
60    Telegram,
61    /// Any other platform name. Per NIP-39 §31 the name MAY contain
62    /// `a-z`, `0-9`, and `._-/` and MUST NOT contain `:`. The
63    /// constructor [`ExternalPlatform::parse`] enforces that.
64    Other(String),
65}
66
67/// Errors raised when constructing external identities.
68#[derive(Debug, Clone, PartialEq, Eq, Error)]
69#[non_exhaustive]
70pub enum Nip39Error {
71    /// The platform identifier was empty.
72    #[error("platform name must not be empty")]
73    EmptyPlatform,
74    /// The platform identifier contained the `:` separator.
75    #[error("platform name must not contain ':' ; got `{0}`")]
76    PlatformContainsColon(String),
77    /// The full `platform:identity` value did not contain a `:`.
78    #[error("expected `<platform>:<identity>`, got `{0}`")]
79    MissingSeparator(String),
80    /// The identity portion was empty.
81    #[error("identity portion of the `i` tag must not be empty")]
82    EmptyIdentity,
83}
84
85impl ExternalPlatform {
86    /// Parse a platform name (the part before the colon in
87    /// `<platform>:<identity>`).
88    ///
89    /// # Errors
90    ///
91    /// - [`Nip39Error::EmptyPlatform`] for `""`.
92    /// - [`Nip39Error::PlatformContainsColon`] when the name contains
93    ///   the `:` separator (which would conflate the platform name
94    ///   with the identity).
95    pub fn parse(name: &str) -> Result<Self, Nip39Error> {
96        if name.is_empty() {
97            return Err(Nip39Error::EmptyPlatform);
98        }
99        if name.contains(':') {
100            return Err(Nip39Error::PlatformContainsColon(name.to_owned()));
101        }
102        Ok(match name {
103            "github" => Self::GitHub,
104            "twitter" => Self::Twitter,
105            "mastodon" => Self::Mastodon,
106            "telegram" => Self::Telegram,
107            other => Self::Other(other.to_owned()),
108        })
109    }
110
111    /// Render the platform back to its canonical wire string.
112    #[must_use]
113    pub const fn as_str(&self) -> &str {
114        match self {
115            Self::GitHub => "github",
116            Self::Twitter => "twitter",
117            Self::Mastodon => "mastodon",
118            Self::Telegram => "telegram",
119            Self::Other(name) => name.as_str(),
120        }
121    }
122}
123
124impl std::fmt::Display for ExternalPlatform {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.write_str(self.as_str())
127    }
128}
129
130/// A single declared external identity.
131///
132/// Build new ones with [`Self::new`] or
133/// [`Self::parse_tag_values`]; render to a [`Tag`] with
134/// [`Tag::external_identity`].
135#[derive(Debug, Clone, PartialEq, Eq, Hash)]
136pub struct Identity {
137    /// The platform on which the user holds an account.
138    pub platform: ExternalPlatform,
139    /// The user's handle / id on that platform. For `mastodon`,
140    /// this includes the instance host (e.g. `bitcoinhackers.org/@semisol`).
141    pub ident: String,
142    /// Platform-specific proof string. The shape is documented per
143    /// platform in NIP-39 §"Claim types".
144    pub proof: String,
145}
146
147impl Identity {
148    /// Construct an identity from the canonical pieces.
149    ///
150    /// # Errors
151    ///
152    /// Returns [`Nip39Error::EmptyIdentity`] when `ident` is empty
153    /// after trimming.
154    pub fn new(
155        platform: ExternalPlatform,
156        ident: impl Into<String>,
157        proof: impl Into<String>,
158    ) -> Result<Self, Nip39Error> {
159        let ident = ident.into();
160        if ident.is_empty() {
161            return Err(Nip39Error::EmptyIdentity);
162        }
163        Ok(Self {
164            platform,
165            ident,
166            proof: proof.into(),
167        })
168    }
169
170    /// Parse the on-the-wire pair `(platform_identity, proof)` of an
171    /// `i` tag's positional arguments.
172    ///
173    /// `platform_identity` is the second element of the tag (the
174    /// concatenated `platform:identity` form). `proof` is the third
175    /// element.
176    ///
177    /// # Errors
178    ///
179    /// - [`Nip39Error::MissingSeparator`] for a `platform_identity`
180    ///   without a `:`.
181    /// - [`Nip39Error::PlatformContainsColon`] propagated from the
182    ///   platform parser.
183    /// - [`Nip39Error::EmptyIdentity`] if the part after `:` is empty.
184    pub fn parse_tag_values(
185        platform_identity: &str,
186        proof: impl Into<String>,
187    ) -> Result<Self, Nip39Error> {
188        let (platform, ident) = platform_identity
189            .split_once(':')
190            .ok_or_else(|| Nip39Error::MissingSeparator(platform_identity.to_owned()))?;
191        let platform = ExternalPlatform::parse(platform)?;
192        Self::new(platform, ident, proof)
193    }
194
195    /// The canonical `platform:identity` join used in the second slot
196    /// of the `i` tag.
197    #[must_use]
198    pub fn platform_identity(&self) -> String {
199        format!("{}:{}", self.platform.as_str(), self.ident)
200    }
201}
202
203impl Tag {
204    /// Build a NIP-39 `i` external-identity tag.
205    ///
206    /// Wire form: `["i", "<platform>:<identity>", "<proof>"]`.
207    ///
208    /// `Tag::i` (the NIP-24 / NIP-73 external content tag) and this
209    /// constructor share the same head letter on purpose — NIP-39
210    /// piggybacks on NIP-24's `i` semantics. The two are
211    /// distinguished by their value count: NIP-39 has at least 3
212    /// (head + platform-identity + proof), NIP-24 / NIP-73 has 2 or
213    /// 3 (head + external-id + optional context).
214    #[must_use]
215    pub fn external_identity(identity: &Identity) -> Self {
216        let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::I));
217        Self::with(
218            &head,
219            [identity.platform_identity(), identity.proof.clone()],
220        )
221    }
222}
223
224/// Iterate over every NIP-39 identity carried by `tags`.
225///
226/// Returns an iterator that:
227///
228/// - skips non-`i` tags;
229/// - skips `i` tags that look like NIP-73 external content (only one
230///   value, or no embedded `:` in the second slot);
231/// - skips malformed entries (empty platform, empty identity) so
232///   downstream filters keep working.
233///
234/// Spec-required forward compat: tags with **more than** three
235/// values are still yielded — the platform / identity / proof
236/// pieces are extracted and the trailing values are dropped. A
237/// future NIP that tacks extra columns on the same `i` shape will
238/// not regress under this reader.
239pub fn identities_from_tags(tags: &Tags) -> impl Iterator<Item = Identity> + use<'_> {
240    tags.iter().filter_map(|tag| {
241        let TagKind::SingleLetter(s) = tag.kind() else {
242            return None;
243        };
244        if s.character != Alphabet::I || s.uppercase {
245            return None;
246        }
247        let values = tag.values();
248        let raw_pi = values.get(1)?;
249        let proof = values.get(2)?;
250        Identity::parse_tag_values(raw_pi.as_str(), proof.clone()).ok()
251    })
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::event::Tag;
258
259    #[test]
260    fn well_known_platforms_round_trip_through_parse_and_display() {
261        for (name, expected) in [
262            ("github", ExternalPlatform::GitHub),
263            ("twitter", ExternalPlatform::Twitter),
264            ("mastodon", ExternalPlatform::Mastodon),
265            ("telegram", ExternalPlatform::Telegram),
266        ] {
267            let parsed = ExternalPlatform::parse(name).unwrap();
268            assert_eq!(parsed, expected);
269            assert_eq!(parsed.as_str(), name);
270            assert_eq!(parsed.to_string(), name);
271        }
272    }
273
274    #[test]
275    fn unknown_platforms_are_preserved_verbatim() {
276        let exotic = ExternalPlatform::parse("matrix").unwrap();
277        assert_eq!(exotic, ExternalPlatform::Other("matrix".into()));
278        assert_eq!(exotic.as_str(), "matrix");
279    }
280
281    #[test]
282    fn platform_parser_rejects_empty_or_colon_bearing_names() {
283        assert_eq!(ExternalPlatform::parse(""), Err(Nip39Error::EmptyPlatform),);
284        assert!(matches!(
285            ExternalPlatform::parse("inva:lid"),
286            Err(Nip39Error::PlatformContainsColon(s)) if s == "inva:lid"
287        ));
288    }
289
290    #[test]
291    fn identity_round_trips_through_a_tag() {
292        let id = Identity::new(ExternalPlatform::GitHub, "semisol", "9721ce4ee4f").unwrap();
293        let tag = Tag::external_identity(&id);
294        assert_eq!(tag.values().len(), 3);
295        assert_eq!(tag.get(0), Some("i"));
296        assert_eq!(tag.get(1), Some("github:semisol"));
297        assert_eq!(tag.get(2), Some("9721ce4ee4f"));
298    }
299
300    #[test]
301    fn parse_tag_values_handles_mastodon_compound_identity() {
302        // Mastodon's identity portion contains `/` and `@`, but no `:`.
303        let id = Identity::parse_tag_values(
304            "mastodon:bitcoinhackers.org/@semisol",
305            "109775066355589974",
306        )
307        .unwrap();
308        assert_eq!(id.platform, ExternalPlatform::Mastodon);
309        assert_eq!(id.ident, "bitcoinhackers.org/@semisol");
310        assert_eq!(
311            id.platform_identity(),
312            "mastodon:bitcoinhackers.org/@semisol"
313        );
314    }
315
316    #[test]
317    fn parse_tag_values_rejects_empty_identity_and_missing_separator() {
318        assert!(matches!(
319            Identity::parse_tag_values("github:", "proof"),
320            Err(Nip39Error::EmptyIdentity)
321        ));
322        assert!(matches!(
323            Identity::parse_tag_values("github", "proof"),
324            Err(Nip39Error::MissingSeparator(s)) if s == "github"
325        ));
326    }
327
328    #[test]
329    fn identities_from_tags_yields_every_well_formed_entry() {
330        let mut tags = Tags::new();
331        tags.push(Tag::external_identity(
332            &Identity::new(ExternalPlatform::GitHub, "alice", "g1").unwrap(),
333        ));
334        tags.push(Tag::external_identity(
335            &Identity::new(ExternalPlatform::Other("matrix".into()), "@a:m.org", "p1").unwrap(),
336        ));
337        // NIP-73-style `i` (only 2 values) — must be ignored.
338        tags.push(Tag::i("isbn:9780306406157"));
339        // Malformed (no separator) — must be skipped, not crash.
340        tags.push(Tag::new(["i", "no-sep-here", "proof"]).unwrap());
341
342        let parsed: Vec<_> = identities_from_tags(&tags).collect();
343        assert_eq!(parsed.len(), 2);
344        assert_eq!(parsed[0].platform, ExternalPlatform::GitHub);
345        assert_eq!(parsed[0].ident, "alice");
346        assert_eq!(parsed[1].platform, ExternalPlatform::Other("matrix".into()));
347    }
348
349    #[test]
350    fn forward_compat_preserves_extra_tag_columns() {
351        // A future NIP could append a 4th column to `i`; our reader
352        // must still extract the three known pieces.
353        let mut tags = Tags::new();
354        tags.push(Tag::new(["i", "github:bob", "g1", "future-extra"]).unwrap());
355        let parsed: Vec<_> = identities_from_tags(&tags).collect();
356        assert_eq!(parsed.len(), 1);
357        assert_eq!(parsed[0].ident, "bob");
358        assert_eq!(parsed[0].proof, "g1");
359    }
360}