Skip to main content

nula_core/nips/
nip42.rs

1//! [NIP-42] Authentication of clients to relays.
2//!
3//! NIP-42 lets a relay challenge a connected client and the client prove
4//! ownership of a public key. The flow is:
5//!
6//! 1. Relay sends `["AUTH", "<challenge>"]`.
7//! 2. Client signs a kind-`22242` event whose tags include
8//!    `["relay", "<relay-url>"]` and `["challenge", "<challenge>"]`, then
9//!    replies with `["AUTH", <event>]`.
10//! 3. Relay verifies the event matches its expected relay URL and
11//!    challenge, and that `created_at` falls inside an acceptable window
12//!    (NIP-42 recommends ±10 minutes).
13//!
14//! This module provides:
15//!
16//! - [`auth_event`] — fluent constructor that yields an [`EventBuilder`]
17//!   pre-populated with the kind, relay tag, and challenge tag.
18//! - [`verify_auth_event`] — full server-side check (kind, tags, freshness).
19//!
20//! [NIP-42]: https://github.com/nostr-protocol/nips/blob/master/42.md
21
22use thiserror::Error;
23
24use crate::event::{Event, EventBuilder, Kind, Tag, TagKind};
25use crate::types::{RelayUrl, RelayUrlError, Timestamp};
26
27/// Wire name of the NIP-42 relay tag (`relay`).
28pub const RELAY_TAG: &str = "relay";
29/// Wire name of the NIP-42 challenge tag (`challenge`).
30pub const CHALLENGE_TAG: &str = "challenge";
31/// Recommended freshness window: 10 minutes on either side of `now`.
32pub const DEFAULT_MAX_AGE_SECS: u64 = 10 * 60;
33
34/// Errors raised when verifying a NIP-42 auth event.
35#[derive(Debug, Clone, Error)]
36#[non_exhaustive]
37pub enum AuthError {
38    /// The event's kind was not `22242`.
39    #[error("expected kind 22242, got {0}")]
40    UnexpectedKind(u16),
41    /// The `relay` tag was missing or empty.
42    #[error("`relay` tag is missing or empty")]
43    MissingRelayTag,
44    /// The `challenge` tag was missing or empty.
45    #[error("`challenge` tag is missing or empty")]
46    MissingChallengeTag,
47    /// The `relay` tag value did not parse as a [`RelayUrl`].
48    #[error(transparent)]
49    InvalidRelay(#[from] RelayUrlError),
50    /// The `relay` tag did not match the expected relay URL.
51    #[error("relay mismatch: expected `{expected}`, got `{got}`")]
52    RelayMismatch {
53        /// Relay URL the verifier expected.
54        expected: String,
55        /// Relay URL the event actually claimed.
56        got: String,
57    },
58    /// The `challenge` tag did not match the expected challenge string.
59    #[error("challenge mismatch")]
60    ChallengeMismatch,
61    /// `created_at` is more than `max_age` seconds old.
62    #[error("auth event is too old: created_at {created_at} vs now {now} (max age {max_age}s)")]
63    TooOld {
64        /// `event.created_at` (seconds since the epoch).
65        created_at: u64,
66        /// Verifier's `now`.
67        now: u64,
68        /// Maximum tolerated age.
69        max_age: u64,
70    },
71    /// `created_at` is more than `max_age` seconds in the future.
72    #[error(
73        "auth event is too far in the future: created_at {created_at} vs now {now} (max skew {max_age}s)"
74    )]
75    TooFuture {
76        /// `event.created_at` (seconds since the epoch).
77        created_at: u64,
78        /// Verifier's `now`.
79        now: u64,
80        /// Maximum tolerated future skew.
81        max_age: u64,
82    },
83}
84
85/// Build a NIP-42 auth event.
86///
87/// The returned [`EventBuilder`] has the kind set to `22242` and carries
88/// the two NIP-42 tags. Callers can attach additional tags or pin
89/// `created_at` before signing.
90#[must_use]
91pub fn auth_event(relay: &RelayUrl, challenge: impl Into<String>) -> EventBuilder {
92    EventBuilder::new(Kind::AUTHENTICATION, "")
93        .tag(Tag::with(
94            &TagKind::from_wire(RELAY_TAG),
95            [relay.as_str().to_owned()],
96        ))
97        .tag(Tag::with(
98            &TagKind::from_wire(CHALLENGE_TAG),
99            [challenge.into()],
100        ))
101}
102
103/// Verify that `event` is a valid NIP-42 auth response for `(relay,
104/// challenge)` at time `now`.
105///
106/// `max_age` bounds the freshness window in seconds: a value of `600`
107/// matches NIP-42's recommended ±10-minute window. The check rejects
108/// events older than `now - max_age` *and* events that come from more than
109/// `max_age` seconds in the future, both of which suggest replay or clock
110/// abuse.
111///
112/// This function does **not** verify the event's signature; call
113/// [`Event::verify`] separately when the event arrives over the wire.
114///
115/// # Errors
116///
117/// Returns the matching [`AuthError`] variant on the first failed check.
118pub fn verify_auth_event(
119    event: &Event,
120    relay: &RelayUrl,
121    challenge: &str,
122    now: Timestamp,
123    max_age: u64,
124) -> Result<(), AuthError> {
125    verify_auth_event_against(event, relay, &[challenge], now, max_age)
126}
127
128/// Verify a NIP-42 auth event against a *set* of in-flight challenges.
129///
130/// Long-lived relay connections may rotate the AUTH challenge; client
131/// implementations sometimes lag behind the latest one. This entry point
132/// accepts any challenge in `accepted` and returns success on the first
133/// match. All other verification rules — kind, relay tag, freshness
134/// window — match [`verify_auth_event`].
135///
136/// `accepted` must be non-empty. An empty slice is treated as
137/// "accept nothing" and produces [`AuthError::ChallengeMismatch`].
138///
139/// As with [`verify_auth_event`], this function does **not** verify the
140/// event's Schnorr signature; call [`Event::verify`] separately.
141///
142/// # Errors
143///
144/// Returns the matching [`AuthError`] variant on the first failed check.
145pub fn verify_auth_event_against(
146    event: &Event,
147    relay: &RelayUrl,
148    accepted: &[&str],
149    now: Timestamp,
150    max_age: u64,
151) -> Result<(), AuthError> {
152    if event.kind != Kind::AUTHENTICATION {
153        return Err(AuthError::UnexpectedKind(event.kind.as_u16()));
154    }
155
156    let relay_tag = TagKind::from_wire(RELAY_TAG);
157    let claimed_relay = event
158        .tags
159        .find_first(&relay_tag)
160        .and_then(|t| t.values().get(1))
161        .ok_or(AuthError::MissingRelayTag)?;
162    let claimed_relay = RelayUrl::parse(claimed_relay)?;
163    if claimed_relay != *relay {
164        return Err(AuthError::RelayMismatch {
165            expected: relay.as_str().to_owned(),
166            got: claimed_relay.as_str().to_owned(),
167        });
168    }
169
170    let challenge_tag = TagKind::from_wire(CHALLENGE_TAG);
171    let claimed_challenge = event
172        .tags
173        .find_first(&challenge_tag)
174        .and_then(|t| t.values().get(1))
175        .filter(|s| !s.is_empty())
176        .ok_or(AuthError::MissingChallengeTag)?;
177    if !accepted.contains(&claimed_challenge.as_str()) {
178        return Err(AuthError::ChallengeMismatch);
179    }
180
181    let now_secs = now.as_secs();
182    let created_at = event.created_at.as_secs();
183    if now_secs > created_at && now_secs.saturating_sub(created_at) > max_age {
184        return Err(AuthError::TooOld {
185            created_at,
186            now: now_secs,
187            max_age,
188        });
189    }
190    if created_at > now_secs && created_at.saturating_sub(now_secs) > max_age {
191        return Err(AuthError::TooFuture {
192            created_at,
193            now: now_secs,
194            max_age,
195        });
196    }
197
198    Ok(())
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::Keys;
205
206    fn keys() -> Keys {
207        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
208    }
209
210    fn relay() -> RelayUrl {
211        RelayUrl::parse("wss://relay.example/").unwrap()
212    }
213
214    fn signed(challenge: &str, ts: Timestamp) -> Event {
215        auth_event(&relay(), challenge)
216            .created_at(ts)
217            .sign_with_keys(&keys())
218            .unwrap()
219    }
220
221    #[test]
222    fn auth_event_builder_sets_kind_and_tags() {
223        let event = signed("c1", Timestamp::from_secs(100));
224        assert_eq!(event.kind, Kind::AUTHENTICATION);
225        let relay_tag = event
226            .tags
227            .find_first(&TagKind::from_wire(RELAY_TAG))
228            .unwrap();
229        assert_eq!(
230            relay_tag.values().get(1).map(String::as_str),
231            Some(relay().as_str())
232        );
233        let challenge_tag = event
234            .tags
235            .find_first(&TagKind::from_wire(CHALLENGE_TAG))
236            .unwrap();
237        assert_eq!(
238            challenge_tag.values().get(1).map(String::as_str),
239            Some("c1")
240        );
241    }
242
243    #[test]
244    fn verify_happy_path() {
245        let event = signed("c1", Timestamp::from_secs(100));
246        verify_auth_event(&event, &relay(), "c1", Timestamp::from_secs(100), 600).unwrap();
247    }
248
249    #[test]
250    fn verify_rejects_wrong_kind() {
251        let event = EventBuilder::text_note("nope")
252            .created_at(Timestamp::from_secs(1))
253            .sign_with_keys(&keys())
254            .unwrap();
255        let err =
256            verify_auth_event(&event, &relay(), "c1", Timestamp::from_secs(1), 600).unwrap_err();
257        assert!(matches!(err, AuthError::UnexpectedKind(1)));
258    }
259
260    #[test]
261    fn verify_rejects_relay_mismatch() {
262        let event = signed("c1", Timestamp::from_secs(1));
263        let other = RelayUrl::parse("wss://other.example/").unwrap();
264        let err =
265            verify_auth_event(&event, &other, "c1", Timestamp::from_secs(1), 600).unwrap_err();
266        assert!(matches!(err, AuthError::RelayMismatch { .. }));
267    }
268
269    #[test]
270    fn verify_rejects_challenge_mismatch() {
271        let event = signed("c1", Timestamp::from_secs(1));
272        let err = verify_auth_event(&event, &relay(), "different", Timestamp::from_secs(1), 600)
273            .unwrap_err();
274        assert!(matches!(err, AuthError::ChallengeMismatch));
275    }
276
277    #[test]
278    fn verify_rejects_old_event() {
279        let event = signed("c1", Timestamp::from_secs(100));
280        let err = verify_auth_event(&event, &relay(), "c1", Timestamp::from_secs(1_000), 100)
281            .unwrap_err();
282        assert!(matches!(err, AuthError::TooOld { .. }));
283    }
284
285    #[test]
286    fn verify_rejects_future_event() {
287        let event = signed("c1", Timestamp::from_secs(2_000));
288        let err = verify_auth_event(&event, &relay(), "c1", Timestamp::from_secs(1_000), 100)
289            .unwrap_err();
290        assert!(matches!(err, AuthError::TooFuture { .. }));
291    }
292
293    #[test]
294    fn verify_rejects_missing_relay_tag() {
295        let event = EventBuilder::new(Kind::AUTHENTICATION, "")
296            .created_at(Timestamp::from_secs(1))
297            .tag(Tag::with(
298                &TagKind::from_wire(CHALLENGE_TAG),
299                ["c1".to_owned()],
300            ))
301            .sign_with_keys(&keys())
302            .unwrap();
303        let err =
304            verify_auth_event(&event, &relay(), "c1", Timestamp::from_secs(1), 600).unwrap_err();
305        assert!(matches!(err, AuthError::MissingRelayTag));
306    }
307
308    #[test]
309    fn verify_rejects_missing_challenge_tag() {
310        let event = EventBuilder::new(Kind::AUTHENTICATION, "")
311            .created_at(Timestamp::from_secs(1))
312            .tag(Tag::with(
313                &TagKind::from_wire(RELAY_TAG),
314                [relay().as_str().to_owned()],
315            ))
316            .sign_with_keys(&keys())
317            .unwrap();
318        let err =
319            verify_auth_event(&event, &relay(), "c1", Timestamp::from_secs(1), 600).unwrap_err();
320        assert!(matches!(err, AuthError::MissingChallengeTag));
321    }
322
323    #[test]
324    fn verify_against_multi_challenge_accepts_any_match() {
325        // Client signs against the older "c1"; server now also offers "c2".
326        let event = signed("c1", Timestamp::from_secs(1));
327        verify_auth_event_against(
328            &event,
329            &relay(),
330            &["c2", "c1"],
331            Timestamp::from_secs(1),
332            600,
333        )
334        .unwrap();
335    }
336
337    #[test]
338    fn verify_against_multi_challenge_rejects_when_none_match() {
339        let event = signed("c1", Timestamp::from_secs(1));
340        let err = verify_auth_event_against(
341            &event,
342            &relay(),
343            &["c2", "c3"],
344            Timestamp::from_secs(1),
345            600,
346        )
347        .unwrap_err();
348        assert!(matches!(err, AuthError::ChallengeMismatch));
349    }
350
351    #[test]
352    fn verify_against_empty_challenge_set_rejects() {
353        let event = signed("c1", Timestamp::from_secs(1));
354        let err = verify_auth_event_against(&event, &relay(), &[], Timestamp::from_secs(1), 600)
355            .unwrap_err();
356        assert!(matches!(err, AuthError::ChallengeMismatch));
357    }
358}