use thiserror::Error;
use crate::event::{Event, EventBuilder, Kind, Tag, TagKind};
use crate::types::{RelayUrl, RelayUrlError, Timestamp};
pub const RELAY_TAG: &str = "relay";
pub const CHALLENGE_TAG: &str = "challenge";
pub const DEFAULT_MAX_AGE_SECS: u64 = 10 * 60;
#[derive(Debug, Clone, Error)]
#[non_exhaustive]
pub enum AuthError {
#[error("expected kind 22242, got {0}")]
UnexpectedKind(u16),
#[error("`relay` tag is missing or empty")]
MissingRelayTag,
#[error("`challenge` tag is missing or empty")]
MissingChallengeTag,
#[error(transparent)]
InvalidRelay(#[from] RelayUrlError),
#[error("relay mismatch: expected `{expected}`, got `{got}`")]
RelayMismatch {
expected: String,
got: String,
},
#[error("challenge mismatch")]
ChallengeMismatch,
#[error("auth event is too old: created_at {created_at} vs now {now} (max age {max_age}s)")]
TooOld {
created_at: u64,
now: u64,
max_age: u64,
},
#[error(
"auth event is too far in the future: created_at {created_at} vs now {now} (max skew {max_age}s)"
)]
TooFuture {
created_at: u64,
now: u64,
max_age: u64,
},
}
#[must_use]
pub fn auth_event(relay: &RelayUrl, challenge: impl Into<String>) -> EventBuilder {
EventBuilder::new(Kind::AUTHENTICATION, "")
.tag(Tag::with(
&TagKind::from_wire(RELAY_TAG),
[relay.as_str().to_owned()],
))
.tag(Tag::with(
&TagKind::from_wire(CHALLENGE_TAG),
[challenge.into()],
))
}
pub fn verify_auth_event(
event: &Event,
relay: &RelayUrl,
challenge: &str,
now: Timestamp,
max_age: u64,
) -> Result<(), AuthError> {
verify_auth_event_against(event, relay, &[challenge], now, max_age)
}
pub fn verify_auth_event_against(
event: &Event,
relay: &RelayUrl,
accepted: &[&str],
now: Timestamp,
max_age: u64,
) -> Result<(), AuthError> {
if event.kind != Kind::AUTHENTICATION {
return Err(AuthError::UnexpectedKind(event.kind.as_u16()));
}
let relay_tag = TagKind::from_wire(RELAY_TAG);
let claimed_relay = event
.tags
.find_first(&relay_tag)
.and_then(|t| t.values().get(1))
.ok_or(AuthError::MissingRelayTag)?;
let claimed_relay = RelayUrl::parse(claimed_relay)?;
if claimed_relay != *relay {
return Err(AuthError::RelayMismatch {
expected: relay.as_str().to_owned(),
got: claimed_relay.as_str().to_owned(),
});
}
let challenge_tag = TagKind::from_wire(CHALLENGE_TAG);
let claimed_challenge = event
.tags
.find_first(&challenge_tag)
.and_then(|t| t.values().get(1))
.filter(|s| !s.is_empty())
.ok_or(AuthError::MissingChallengeTag)?;
if !accepted.contains(&claimed_challenge.as_str()) {
return Err(AuthError::ChallengeMismatch);
}
let now_secs = now.as_secs();
let created_at = event.created_at.as_secs();
if now_secs > created_at && now_secs.saturating_sub(created_at) > max_age {
return Err(AuthError::TooOld {
created_at,
now: now_secs,
max_age,
});
}
if created_at > now_secs && created_at.saturating_sub(now_secs) > max_age {
return Err(AuthError::TooFuture {
created_at,
now: now_secs,
max_age,
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Keys;
fn keys() -> Keys {
Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
}
fn relay() -> RelayUrl {
RelayUrl::parse("wss://relay.example/").unwrap()
}
fn signed(challenge: &str, ts: Timestamp) -> Event {
auth_event(&relay(), challenge)
.created_at(ts)
.sign_with_keys(&keys())
.unwrap()
}
#[test]
fn auth_event_builder_sets_kind_and_tags() {
let event = signed("c1", Timestamp::from_secs(100));
assert_eq!(event.kind, Kind::AUTHENTICATION);
let relay_tag = event
.tags
.find_first(&TagKind::from_wire(RELAY_TAG))
.unwrap();
assert_eq!(
relay_tag.values().get(1).map(String::as_str),
Some(relay().as_str())
);
let challenge_tag = event
.tags
.find_first(&TagKind::from_wire(CHALLENGE_TAG))
.unwrap();
assert_eq!(
challenge_tag.values().get(1).map(String::as_str),
Some("c1")
);
}
#[test]
fn verify_happy_path() {
let event = signed("c1", Timestamp::from_secs(100));
verify_auth_event(&event, &relay(), "c1", Timestamp::from_secs(100), 600).unwrap();
}
#[test]
fn verify_rejects_wrong_kind() {
let event = EventBuilder::text_note("nope")
.created_at(Timestamp::from_secs(1))
.sign_with_keys(&keys())
.unwrap();
let err =
verify_auth_event(&event, &relay(), "c1", Timestamp::from_secs(1), 600).unwrap_err();
assert!(matches!(err, AuthError::UnexpectedKind(1)));
}
#[test]
fn verify_rejects_relay_mismatch() {
let event = signed("c1", Timestamp::from_secs(1));
let other = RelayUrl::parse("wss://other.example/").unwrap();
let err =
verify_auth_event(&event, &other, "c1", Timestamp::from_secs(1), 600).unwrap_err();
assert!(matches!(err, AuthError::RelayMismatch { .. }));
}
#[test]
fn verify_rejects_challenge_mismatch() {
let event = signed("c1", Timestamp::from_secs(1));
let err = verify_auth_event(&event, &relay(), "different", Timestamp::from_secs(1), 600)
.unwrap_err();
assert!(matches!(err, AuthError::ChallengeMismatch));
}
#[test]
fn verify_rejects_old_event() {
let event = signed("c1", Timestamp::from_secs(100));
let err = verify_auth_event(&event, &relay(), "c1", Timestamp::from_secs(1_000), 100)
.unwrap_err();
assert!(matches!(err, AuthError::TooOld { .. }));
}
#[test]
fn verify_rejects_future_event() {
let event = signed("c1", Timestamp::from_secs(2_000));
let err = verify_auth_event(&event, &relay(), "c1", Timestamp::from_secs(1_000), 100)
.unwrap_err();
assert!(matches!(err, AuthError::TooFuture { .. }));
}
#[test]
fn verify_rejects_missing_relay_tag() {
let event = EventBuilder::new(Kind::AUTHENTICATION, "")
.created_at(Timestamp::from_secs(1))
.tag(Tag::with(
&TagKind::from_wire(CHALLENGE_TAG),
["c1".to_owned()],
))
.sign_with_keys(&keys())
.unwrap();
let err =
verify_auth_event(&event, &relay(), "c1", Timestamp::from_secs(1), 600).unwrap_err();
assert!(matches!(err, AuthError::MissingRelayTag));
}
#[test]
fn verify_rejects_missing_challenge_tag() {
let event = EventBuilder::new(Kind::AUTHENTICATION, "")
.created_at(Timestamp::from_secs(1))
.tag(Tag::with(
&TagKind::from_wire(RELAY_TAG),
[relay().as_str().to_owned()],
))
.sign_with_keys(&keys())
.unwrap();
let err =
verify_auth_event(&event, &relay(), "c1", Timestamp::from_secs(1), 600).unwrap_err();
assert!(matches!(err, AuthError::MissingChallengeTag));
}
#[test]
fn verify_against_multi_challenge_accepts_any_match() {
let event = signed("c1", Timestamp::from_secs(1));
verify_auth_event_against(
&event,
&relay(),
&["c2", "c1"],
Timestamp::from_secs(1),
600,
)
.unwrap();
}
#[test]
fn verify_against_multi_challenge_rejects_when_none_match() {
let event = signed("c1", Timestamp::from_secs(1));
let err = verify_auth_event_against(
&event,
&relay(),
&["c2", "c3"],
Timestamp::from_secs(1),
600,
)
.unwrap_err();
assert!(matches!(err, AuthError::ChallengeMismatch));
}
#[test]
fn verify_against_empty_challenge_set_rejects() {
let event = signed("c1", Timestamp::from_secs(1));
let err = verify_auth_event_against(&event, &relay(), &[], Timestamp::from_secs(1), 600)
.unwrap_err();
assert!(matches!(err, AuthError::ChallengeMismatch));
}
}