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}