lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 12020a0d56e3570e47a51d85fdec716ce8f29ff8
parent cb9bbde65a6dec710e9628c084f3fd56659b1a21
Author: triesap <tyson@radroots.org>
Date:   Thu, 11 Jun 2026 17:24:22 -0700

events_codec: add auth codecs

- add NIP-42 relay auth tag encoding and decoding
- add NIP-98 HTTP auth tag encoding and decoding
- validate empty content and required auth tags with focused tests
- remove an unused base64url tag helper after concrete codec use settled

Diffstat:
Mcrates/events_codec/src/field_helpers.rs | 12------------
Acrates/events_codec/src/http_auth/decode.rs | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/src/http_auth/encode.rs | 48++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/src/http_auth/mod.rs | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events_codec/src/lib.rs | 2++
Acrates/events_codec/src/relay_auth/decode.rs | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/src/relay_auth/encode.rs | 44++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/src/relay_auth/mod.rs | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 458 insertions(+), 12 deletions(-)

diff --git a/crates/events_codec/src/field_helpers.rs b/crates/events_codec/src/field_helpers.rs @@ -127,18 +127,6 @@ pub(crate) fn validate_non_empty_base64url( } } -#[cfg(feature = "serde_json")] -pub(crate) fn validate_non_empty_base64url_tag( - value: &str, - tag: &'static str, -) -> Result<(), EventParseError> { - if is_non_empty_base64url(value) { - Ok(()) - } else { - Err(EventParseError::InvalidTag(tag)) - } -} - pub(crate) fn validate_non_empty_field( value: &str, field: &'static str, diff --git a/crates/events_codec/src/http_auth/decode.rs b/crates/events_codec/src/http_auth/decode.rs @@ -0,0 +1,91 @@ +#[cfg(not(feature = "std"))] +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; + +use radroots_events::{ + RadrootsNostrEvent, + http_auth::{KIND_HTTP_AUTH, RadrootsHttpAuth}, + tags::{TAG_METHOD, TAG_PAYLOAD, TAG_URL_AUTH}, +}; + +use crate::error::EventParseError; +use crate::field_helpers::{ + optional_tag_value, require_empty_content, required_tag_value, validate_lowercase_hex_64_tag, +}; +use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent}; + +const EXPECTED_KIND: &str = "27235"; + +pub fn http_auth_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsHttpAuth, EventParseError> { + if kind != KIND_HTTP_AUTH { + return Err(EventParseError::InvalidKind { + expected: EXPECTED_KIND, + got: kind, + }); + } + require_empty_content(content, "content")?; + let payload_sha256 = optional_tag_value(tags, TAG_PAYLOAD)?; + if let Some(payload) = payload_sha256.as_deref() { + validate_lowercase_hex_64_tag(payload, TAG_PAYLOAD)?; + } + Ok(RadrootsHttpAuth { + url: required_tag_value(tags, TAG_URL_AUTH)?, + method: required_tag_value(tags, TAG_METHOD)?, + payload_sha256, + }) +} + +pub fn data_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, +) -> Result<RadrootsParsedData<RadrootsHttpAuth>, EventParseError> { + let auth = http_auth_from_event(kind, &tags, &content)?; + Ok(RadrootsParsedData::new( + id, + author, + published_at, + kind, + auth, + )) +} + +pub fn parsed_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, + sig: String, +) -> Result<RadrootsParsedEvent<RadrootsHttpAuth>, EventParseError> { + let data = data_from_event( + id.clone(), + author.clone(), + published_at, + kind, + content.clone(), + tags.clone(), + )?; + Ok(RadrootsParsedEvent { + event: RadrootsNostrEvent { + id, + author, + created_at: published_at, + kind, + content, + tags, + sig, + }, + data, + }) +} diff --git a/crates/events_codec/src/http_auth/encode.rs b/crates/events_codec/src/http_auth/encode.rs @@ -0,0 +1,48 @@ +#[cfg(not(feature = "std"))] +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; + +use radroots_events::{ + http_auth::{KIND_HTTP_AUTH, RadrootsHttpAuth}, + tags::{TAG_METHOD, TAG_PAYLOAD, TAG_URL_AUTH}, +}; + +use crate::error::EventEncodeError; +use crate::field_helpers::{ + push_optional_tag, push_tag, validate_lowercase_hex_64, validate_non_empty_field, +}; +use crate::wire::WireEventParts; + +pub fn http_auth_build_tags(auth: &RadrootsHttpAuth) -> Result<Vec<Vec<String>>, EventEncodeError> { + validate_non_empty_field(&auth.url, "url")?; + validate_non_empty_field(&auth.method, "method")?; + if let Some(payload) = auth.payload_sha256.as_deref() { + validate_lowercase_hex_64(payload, "payload_sha256")?; + } + let mut tags = Vec::new(); + push_tag(&mut tags, TAG_URL_AUTH, auth.url.as_str()); + push_tag(&mut tags, TAG_METHOD, auth.method.as_str()); + push_optional_tag(&mut tags, TAG_PAYLOAD, auth.payload_sha256.as_deref()); + Ok(tags) +} + +pub fn to_wire_parts(auth: &RadrootsHttpAuth) -> Result<WireEventParts, EventEncodeError> { + to_wire_parts_with_kind(auth, KIND_HTTP_AUTH) +} + +pub fn to_wire_parts_with_kind( + auth: &RadrootsHttpAuth, + kind: u32, +) -> Result<WireEventParts, EventEncodeError> { + if kind != KIND_HTTP_AUTH { + return Err(EventEncodeError::InvalidKind(kind)); + } + let tags = http_auth_build_tags(auth)?; + Ok(WireEventParts { + kind, + content: String::new(), + tags, + }) +} diff --git a/crates/events_codec/src/http_auth/mod.rs b/crates/events_codec/src/http_auth/mod.rs @@ -0,0 +1,109 @@ +pub mod decode; +pub mod encode; + +#[cfg(test)] +mod tests { + use radroots_events::{http_auth::RadrootsHttpAuth, kinds::KIND_POST}; + + use crate::error::{EventEncodeError, EventParseError}; + use crate::http_auth::decode::http_auth_from_event; + use crate::http_auth::encode::{http_auth_build_tags, to_wire_parts, to_wire_parts_with_kind}; + + const PAYLOAD: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + #[test] + fn http_auth_encodes_and_decodes_get_without_payload() { + let auth = RadrootsHttpAuth { + url: "https://media.example.invalid/download".to_string(), + method: "GET".to_string(), + payload_sha256: None, + }; + let parts = to_wire_parts(&auth).expect("http auth wire parts"); + + assert_eq!(parts.kind, 27235); + assert_eq!(parts.content, ""); + assert!(parts.tags.contains(&tag("u", auth.url.as_str()))); + assert!(parts.tags.contains(&tag("method", "GET"))); + + let decoded = + http_auth_from_event(parts.kind, &parts.tags, &parts.content).expect("decode"); + assert_eq!(decoded, auth); + } + + #[test] + fn http_auth_encodes_and_decodes_post_with_payload() { + let auth = RadrootsHttpAuth { + url: "https://media.example.invalid/upload".to_string(), + method: "POST".to_string(), + payload_sha256: Some(PAYLOAD.to_string()), + }; + let parts = to_wire_parts(&auth).expect("http auth wire parts"); + + assert!(parts.tags.contains(&tag("payload", PAYLOAD))); + let decoded = + http_auth_from_event(parts.kind, &parts.tags, &parts.content).expect("decode"); + assert_eq!(decoded.payload_sha256.as_deref(), Some(PAYLOAD)); + } + + #[test] + fn http_auth_rejects_missing_url_missing_method_bad_payload_and_content() { + let auth = RadrootsHttpAuth { + url: "https://media.example.invalid/upload".to_string(), + method: "POST".to_string(), + payload_sha256: Some(PAYLOAD.to_string()), + }; + let parts = to_wire_parts(&auth).expect("http auth wire parts"); + let without_url = parts + .tags + .iter() + .filter(|tag| tag.first().map(|value| value.as_str()) != Some("u")) + .cloned() + .collect::<Vec<_>>(); + let missing_url = http_auth_from_event(parts.kind, &without_url, "").unwrap_err(); + assert!(matches!(missing_url, EventParseError::MissingTag("u"))); + + let without_method = parts + .tags + .iter() + .filter(|tag| tag.first().map(|value| value.as_str()) != Some("method")) + .cloned() + .collect::<Vec<_>>(); + let missing_method = http_auth_from_event(parts.kind, &without_method, "").unwrap_err(); + assert!(matches!( + missing_method, + EventParseError::MissingTag("method") + )); + + let mut bad_payload = auth.clone(); + bad_payload.payload_sha256 = Some("ABC".to_string()); + let payload_err = http_auth_build_tags(&bad_payload).unwrap_err(); + assert!(matches!( + payload_err, + EventEncodeError::InvalidField("payload_sha256") + )); + + let content_err = http_auth_from_event(parts.kind, &parts.tags, "not empty").unwrap_err(); + assert!(matches!( + content_err, + EventParseError::InvalidJson("content") + )); + } + + #[test] + fn http_auth_rejects_wrong_kind() { + let auth = RadrootsHttpAuth { + url: "https://media.example.invalid/upload".to_string(), + method: "POST".to_string(), + payload_sha256: Some(PAYLOAD.to_string()), + }; + let wrong_kind = to_wire_parts_with_kind(&auth, KIND_POST).unwrap_err(); + assert!(matches!( + wrong_kind, + EventEncodeError::InvalidKind(KIND_POST) + )); + } + + fn tag(key: &str, value: &str) -> Vec<String> { + vec![key.to_string(), value.to_string()] + } +} diff --git a/crates/events_codec/src/lib.rs b/crates/events_codec/src/lib.rs @@ -24,11 +24,13 @@ pub mod farm_workspace; pub mod follow; pub mod geochat; pub mod gift_wrap; +pub mod http_auth; pub mod message; pub mod message_file; pub mod plot; pub mod post; pub mod reaction; +pub mod relay_auth; pub mod resource_area; pub mod resource_cap; pub mod seal; diff --git a/crates/events_codec/src/relay_auth/decode.rs b/crates/events_codec/src/relay_auth/decode.rs @@ -0,0 +1,84 @@ +#[cfg(not(feature = "std"))] +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; + +use radroots_events::{ + RadrootsNostrEvent, + relay_auth::{KIND_RELAY_AUTH, RadrootsRelayAuth}, + tags::{TAG_CHALLENGE, TAG_RELAY}, +}; + +use crate::error::EventParseError; +use crate::field_helpers::{require_empty_content, required_tag_value}; +use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent}; + +const EXPECTED_KIND: &str = "22242"; + +pub fn relay_auth_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsRelayAuth, EventParseError> { + if kind != KIND_RELAY_AUTH { + return Err(EventParseError::InvalidKind { + expected: EXPECTED_KIND, + got: kind, + }); + } + require_empty_content(content, "content")?; + Ok(RadrootsRelayAuth { + relay: required_tag_value(tags, TAG_RELAY)?, + challenge: required_tag_value(tags, TAG_CHALLENGE)?, + }) +} + +pub fn data_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, +) -> Result<RadrootsParsedData<RadrootsRelayAuth>, EventParseError> { + let auth = relay_auth_from_event(kind, &tags, &content)?; + Ok(RadrootsParsedData::new( + id, + author, + published_at, + kind, + auth, + )) +} + +pub fn parsed_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, + sig: String, +) -> Result<RadrootsParsedEvent<RadrootsRelayAuth>, EventParseError> { + let data = data_from_event( + id.clone(), + author.clone(), + published_at, + kind, + content.clone(), + tags.clone(), + )?; + Ok(RadrootsParsedEvent { + event: RadrootsNostrEvent { + id, + author, + created_at: published_at, + kind, + content, + tags, + sig, + }, + data, + }) +} diff --git a/crates/events_codec/src/relay_auth/encode.rs b/crates/events_codec/src/relay_auth/encode.rs @@ -0,0 +1,44 @@ +#[cfg(not(feature = "std"))] +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; + +use radroots_events::{ + relay_auth::{KIND_RELAY_AUTH, RadrootsRelayAuth}, + tags::{TAG_CHALLENGE, TAG_RELAY}, +}; + +use crate::error::EventEncodeError; +use crate::field_helpers::{push_tag, validate_non_empty_field}; +use crate::wire::WireEventParts; + +pub fn relay_auth_build_tags( + auth: &RadrootsRelayAuth, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + validate_non_empty_field(&auth.relay, "relay")?; + validate_non_empty_field(&auth.challenge, "challenge")?; + let mut tags = Vec::new(); + push_tag(&mut tags, TAG_RELAY, auth.relay.as_str()); + push_tag(&mut tags, TAG_CHALLENGE, auth.challenge.as_str()); + Ok(tags) +} + +pub fn to_wire_parts(auth: &RadrootsRelayAuth) -> Result<WireEventParts, EventEncodeError> { + to_wire_parts_with_kind(auth, KIND_RELAY_AUTH) +} + +pub fn to_wire_parts_with_kind( + auth: &RadrootsRelayAuth, + kind: u32, +) -> Result<WireEventParts, EventEncodeError> { + if kind != KIND_RELAY_AUTH { + return Err(EventEncodeError::InvalidKind(kind)); + } + let tags = relay_auth_build_tags(auth)?; + Ok(WireEventParts { + kind, + content: String::new(), + tags, + }) +} diff --git a/crates/events_codec/src/relay_auth/mod.rs b/crates/events_codec/src/relay_auth/mod.rs @@ -0,0 +1,80 @@ +pub mod decode; +pub mod encode; + +#[cfg(test)] +mod tests { + use radroots_events::{kinds::KIND_POST, relay_auth::RadrootsRelayAuth}; + + use crate::error::{EventEncodeError, EventParseError}; + use crate::relay_auth::decode::relay_auth_from_event; + use crate::relay_auth::encode::{ + relay_auth_build_tags, to_wire_parts, to_wire_parts_with_kind, + }; + + #[test] + fn relay_auth_encodes_and_decodes_nip42_event() { + let auth = sample_auth(); + let parts = to_wire_parts(&auth).expect("relay auth wire parts"); + + assert_eq!(parts.kind, 22242); + assert_eq!(parts.content, ""); + assert!(parts.tags.contains(&tag("relay", auth.relay.as_str()))); + assert!( + parts + .tags + .contains(&tag("challenge", auth.challenge.as_str())) + ); + + let decoded = + relay_auth_from_event(parts.kind, &parts.tags, &parts.content).expect("decode"); + assert_eq!(decoded, auth); + } + + #[test] + fn relay_auth_rejects_missing_challenge_non_empty_content_and_wrong_kind() { + let parts = to_wire_parts(&sample_auth()).expect("relay auth wire parts"); + let without_challenge = parts + .tags + .iter() + .filter(|tag| tag.first().map(|value| value.as_str()) != Some("challenge")) + .cloned() + .collect::<Vec<_>>(); + let missing = + relay_auth_from_event(parts.kind, &without_challenge, &parts.content).unwrap_err(); + assert!(matches!(missing, EventParseError::MissingTag("challenge"))); + + let content_err = relay_auth_from_event(parts.kind, &parts.tags, "not empty").unwrap_err(); + assert!(matches!( + content_err, + EventParseError::InvalidJson("content") + )); + + let wrong_kind = to_wire_parts_with_kind(&sample_auth(), KIND_POST).unwrap_err(); + assert!(matches!( + wrong_kind, + EventEncodeError::InvalidKind(KIND_POST) + )); + } + + #[test] + fn relay_auth_rejects_empty_required_fields() { + let mut auth = sample_auth(); + auth.relay.clear(); + let relay_err = relay_auth_build_tags(&auth).unwrap_err(); + assert!(matches!( + relay_err, + EventEncodeError::EmptyRequiredField("relay") + )); + } + + fn sample_auth() -> RadrootsRelayAuth { + RadrootsRelayAuth { + relay: "wss://relay.example.invalid/farm/field-group".to_string(), + challenge: "relay-provided-challenge".to_string(), + } + } + + fn tag(key: &str, value: &str) -> Vec<String> { + vec![key.to_string(), value.to_string()] + } +}