lib

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

commit 2e55fd5d76fd15e8e5f082c32f24588c6959c8a4
parent b8ed88d8cd99f3d1c3c84ee6925b621ecf4c49bf
Author: triesap <tyson@radroots.org>
Date:   Thu,  9 Jul 2026 04:28:39 +0000

events_codec: reject duplicate http auth tags

- Adds a duplicate-tag parse error for singleton event tags.
- Makes NIP-98 HTTP auth reject duplicate u, method, and payload tags.
- Preserves order-envelope error mapping for duplicate tag inputs.
- Documents and tests the stricter HTTP auth parse contract.

Diffstat:
Mcrates/events_codec/README | 4+++-
Mcrates/events_codec/src/error.rs | 3+++
Mcrates/events_codec/src/field_helpers.rs | 35+++++++++++++++++++++++++++++++++++
Mcrates/events_codec/src/http_auth/decode.rs | 9+++++----
Mcrates/events_codec/src/http_auth/mod.rs | 20++++++++++++++++++++
Mcrates/events_codec/src/order/decode.rs | 3++-
6 files changed, 68 insertions(+), 6 deletions(-)

diff --git a/crates/events_codec/README b/crates/events_codec/README @@ -27,7 +27,9 @@ The Field codec surface validates the public Nostr event substrate exposed by address, a farm group id, an owner document tag, lowercase SHA-256 hashes, and optional caption text as content; * NIP-42 relay auth and NIP-98 HTTP auth events require empty content and the - auth tags required by their protocols; + auth tags required by their protocols; NIP-98 `u`, `method`, and `payload` + tags are parsed as singleton security tags and duplicate occurrences fail + closed; * NIP-29 group codecs preserve the protocol distinction between `h`-routed group operations and `d`-routed addressable group state for the supported `9000`, `9001`, `9002`, `9005`, `9007`, `9008`, `9009`, `9021`, `9022`, diff --git a/crates/events_codec/src/error.rs b/crates/events_codec/src/error.rs @@ -4,6 +4,7 @@ use core::fmt; pub enum EventParseError { MissingTag(&'static str), InvalidTag(&'static str), + DuplicateTag(&'static str), InvalidKind { expected: &'static str, got: u32 }, InvalidNumber(&'static str, core::num::ParseIntError), InvalidJson(&'static str), @@ -14,6 +15,7 @@ impl EventParseError { match self { Self::MissingTag(_) => "missing_tag", Self::InvalidTag(_) => "invalid_tag", + Self::DuplicateTag(_) => "duplicate_tag", Self::InvalidKind { .. } => "invalid_kind", Self::InvalidNumber(_, _) => "invalid_number", Self::InvalidJson(_) => "invalid_json", @@ -26,6 +28,7 @@ impl fmt::Display for EventParseError { match self { EventParseError::MissingTag(t) => write!(f, "missing tag: {}", t), EventParseError::InvalidTag(t) => write!(f, "invalid tag structure for '{}'", t), + EventParseError::DuplicateTag(t) => write!(f, "duplicate tag: {}", t), EventParseError::InvalidKind { expected, got } => { write!(f, "invalid kind {} (expected {})", got, expected) } diff --git a/crates/events_codec/src/field_helpers.rs b/crates/events_codec/src/field_helpers.rs @@ -184,6 +184,13 @@ pub(crate) fn required_tag_value( }) } +pub(crate) fn required_unique_tag_value( + tags: &[Vec<String>], + key: &'static str, +) -> Result<String, EventParseError> { + unique_tag_value(tags, key)?.ok_or(EventParseError::MissingTag(key)) +} + pub(crate) fn optional_tag_value( tags: &[Vec<String>], key: &'static str, @@ -202,6 +209,34 @@ pub(crate) fn optional_tag_value( Ok(Some(value)) } +pub(crate) fn optional_unique_tag_value( + tags: &[Vec<String>], + key: &'static str, +) -> Result<Option<String>, EventParseError> { + unique_tag_value(tags, key) +} + +fn unique_tag_value( + tags: &[Vec<String>], + key: &'static str, +) -> Result<Option<String>, EventParseError> { + let mut matches = tags + .iter() + .filter(|tag| tag.first().map(|value| value.as_str()) == Some(key)); + let Some(tag) = matches.next() else { + return Ok(None); + }; + if matches.next().is_some() { + return Err(EventParseError::DuplicateTag(key)); + } + let value = tag + .get(1) + .map(ToString::to_string) + .ok_or(EventParseError::InvalidTag(key))?; + validate_non_empty_tag_value(&value, key)?; + Ok(Some(value)) +} + pub(crate) fn tag_values( tags: &[Vec<String>], key: &'static str, diff --git a/crates/events_codec/src/http_auth/decode.rs b/crates/events_codec/src/http_auth/decode.rs @@ -9,7 +9,8 @@ use radroots_events::{ use crate::error::EventParseError; use crate::field_helpers::{ - optional_tag_value, require_empty_content, required_tag_value, validate_lowercase_hex_64_tag, + optional_unique_tag_value, require_empty_content, required_unique_tag_value, + validate_lowercase_hex_64_tag, }; use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent}; @@ -27,13 +28,13 @@ pub fn http_auth_from_event( }); } require_empty_content(content, "content")?; - let payload_sha256 = optional_tag_value(tags, TAG_PAYLOAD)?; + let payload_sha256 = optional_unique_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)?, + url: required_unique_tag_value(tags, TAG_URL_AUTH)?, + method: required_unique_tag_value(tags, TAG_METHOD)?, payload_sha256, }) } diff --git a/crates/events_codec/src/http_auth/mod.rs b/crates/events_codec/src/http_auth/mod.rs @@ -143,6 +143,26 @@ mod tests { } #[test] + fn http_auth_rejects_duplicate_security_tags() { + 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"); + + for key in ["u", "method", "payload"] { + let mut duplicate = parts.tags.clone(); + duplicate.push(tag(key, "duplicate")); + let err = http_auth_from_event(parts.kind, &duplicate, "").unwrap_err(); + assert!( + matches!(err, EventParseError::DuplicateTag(tag) if tag == key), + "expected duplicate {key}, got {err:?}" + ); + } + } + + #[test] fn http_auth_wrappers_preserve_event_metadata() { let auth = RadrootsHttpAuth { url: "https://media.example.invalid/upload".to_string(), diff --git a/crates/events_codec/src/order/decode.rs b/crates/events_codec/src/order/decode.rs @@ -363,7 +363,8 @@ fn map_tag_parse_error_for_order_envelope( crate::error::EventParseError::MissingTag(tag) => { RadrootsOrderEnvelopeParseError::MissingTag(tag) } - crate::error::EventParseError::InvalidTag(tag) => { + crate::error::EventParseError::InvalidTag(tag) + | crate::error::EventParseError::DuplicateTag(tag) => { RadrootsOrderEnvelopeParseError::InvalidTag(tag) } crate::error::EventParseError::InvalidKind { expected: _, got } => {