lib

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

commit 39c5400f4f5d86cffced69bc91906a4f607af3d9
parent e2c4871159ee4f025b8669df2a25e54a222ee662
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 13:17:29 -0700

authority: validate signed event draft fields

- compare signed events against every frozen draft field
- recompute NIP-01 ids before accepting signer output
- expose the reusable signed event draft validator
- cover malformed signer output with focused tests

Diffstat:
Mcrates/authority/src/authorization.rs | 252++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/authority/src/error.rs | 41+++++++++++++++++++++++++++++++++++++++--
Mcrates/authority/src/lib.rs | 2+-
3 files changed, 279 insertions(+), 16 deletions(-)

diff --git a/crates/authority/src/authorization.rs b/crates/authority/src/authorization.rs @@ -4,12 +4,14 @@ use crate::{RadrootsActorContext, RadrootsAuthorityError, RadrootsEventSigner}; use radroots_events::contract::{RadrootsEventContract, event_contract}; #[cfg(test)] use radroots_events::draft::RadrootsSignedNostrEventParts; -use radroots_events::draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent}; +use radroots_events::draft::{ + RadrootsFrozenEventDraft, RadrootsSignedNostrEvent, compute_nip01_event_id, +}; #[cfg(not(feature = "std"))] -use alloc::borrow::ToOwned; +use alloc::{borrow::ToOwned, string::ToString}; #[cfg(feature = "std")] -use std::borrow::ToOwned; +use std::{borrow::ToOwned, string::ToString}; pub fn authorize_actor_for_contract( actor: &RadrootsActorContext, @@ -79,19 +81,70 @@ where authorize_actor_for_draft(actor, draft)?; authorize_signer_for_draft(signer, draft)?; let signed_event = signer.sign_frozen_draft(draft)?; + validate_signed_event_matches_draft(&signed_event, draft)?; + Ok(signed_event) +} + +pub fn validate_signed_event_matches_draft( + signed_event: &RadrootsSignedNostrEvent, + draft: &RadrootsFrozenEventDraft, +) -> Result<(), RadrootsAuthorityError> { if signed_event.pubkey.as_str() != draft.expected_pubkey.as_str() { return Err(RadrootsAuthorityError::SignedEventPubkeyMismatch { expected_pubkey: draft.expected_pubkey.clone(), - actual_pubkey: signed_event.pubkey, + actual_pubkey: signed_event.pubkey.clone(), }); } if signed_event.id.as_str() != draft.expected_event_id.as_str() { return Err(RadrootsAuthorityError::SignedEventIdMismatch { expected_event_id: draft.expected_event_id.clone(), - actual_event_id: signed_event.id, + actual_event_id: signed_event.id.clone(), }); } - Ok(signed_event) + if signed_event.created_at != draft.created_at { + return Err(RadrootsAuthorityError::SignedEventCreatedAtMismatch { + expected_created_at: draft.created_at, + actual_created_at: signed_event.created_at, + }); + } + if signed_event.kind != draft.kind { + return Err(RadrootsAuthorityError::SignedEventKindMismatch { + expected_kind: draft.kind, + actual_kind: signed_event.kind, + }); + } + if signed_event.tags != draft.tags { + return Err(RadrootsAuthorityError::SignedEventTagsMismatch { + expected_tags: draft.tags.clone(), + actual_tags: signed_event.tags.clone(), + }); + } + if signed_event.content != draft.content { + return Err(RadrootsAuthorityError::SignedEventContentMismatch { + expected_content: draft.content.clone(), + actual_content: signed_event.content.clone(), + }); + } + let computed_event_id = compute_nip01_event_id( + signed_event.pubkey.as_str(), + signed_event.created_at, + signed_event.kind, + &signed_event.tags, + signed_event.content.as_str(), + ) + .map_err( + |error| RadrootsAuthorityError::SignedEventComputedIdInvalid { + message: error.to_string(), + }, + )? + .into_string(); + if computed_event_id.as_str() != signed_event.id.as_str() { + return Err(RadrootsAuthorityError::SignedEventComputedIdMismatch { + expected_event_id: signed_event.id.clone(), + computed_event_id, + }); + } + Ok(()) } #[cfg(test)] @@ -130,23 +183,42 @@ mod tests { .expect("listing draft") } + #[derive(Default)] + struct SignedEventOverrides { + event_id: Option<String>, + created_at: Option<u32>, + kind: Option<u32>, + tags: Option<Vec<Vec<String>>>, + content: Option<String>, + } + struct StaticSigner { pubkey: RadrootsPublicKey, - event_id: Option<String>, + overrides: SignedEventOverrides, } impl StaticSigner { fn new(pubkey: &str) -> Self { Self { pubkey: RadrootsPublicKey::parse(pubkey).expect("pubkey"), - event_id: None, + overrides: SignedEventOverrides::default(), } } fn with_event_id(pubkey: &str, event_id: String) -> Self { + Self::with_overrides( + pubkey, + SignedEventOverrides { + event_id: Some(event_id), + ..SignedEventOverrides::default() + }, + ) + } + + fn with_overrides(pubkey: &str, overrides: SignedEventOverrides) -> Self { Self { pubkey: RadrootsPublicKey::parse(pubkey).expect("pubkey"), - event_id: Some(event_id), + overrides, } } } @@ -162,15 +234,24 @@ mod tests { ) -> Result<RadrootsSignedNostrEvent, RadrootsSignerError> { RadrootsSignedNostrEvent::new(RadrootsSignedNostrEventParts { id: self + .overrides .event_id .as_deref() .unwrap_or(draft.expected_event_id.as_str()) .to_owned(), pubkey: self.pubkey.to_string(), - created_at: draft.created_at, - kind: draft.kind, - tags: draft.tags.clone(), - content: draft.content.clone(), + created_at: self.overrides.created_at.unwrap_or(draft.created_at), + kind: self.overrides.kind.unwrap_or(draft.kind), + tags: self + .overrides + .tags + .clone() + .unwrap_or_else(|| draft.tags.clone()), + content: self + .overrides + .content + .clone() + .unwrap_or_else(|| draft.content.clone()), sig: hex_128('f'), raw_json: "{}".to_owned(), }) @@ -180,6 +261,20 @@ mod tests { } } + fn signed_event_from_draft(draft: &RadrootsFrozenEventDraft) -> RadrootsSignedNostrEvent { + RadrootsSignedNostrEvent::new(RadrootsSignedNostrEventParts { + id: draft.expected_event_id.clone(), + pubkey: draft.expected_pubkey.clone(), + created_at: draft.created_at, + kind: draft.kind, + tags: draft.tags.clone(), + content: draft.content.clone(), + sig: hex_128('f'), + raw_json: "{}".to_owned(), + }) + .expect("signed event") + } + #[test] fn buyer_and_seller_contract_roles_match_current_contracts() { let listing = event_contract("radroots.listing.published.v1").expect("listing contract"); @@ -274,6 +369,137 @@ mod tests { } #[test] + fn signed_event_created_at_mismatch_fails() { + let pubkey = hex_64('a'); + let draft = listing_draft(pubkey.as_str()); + let actor = seller_actor(pubkey.as_str()); + let signer = StaticSigner::with_overrides( + pubkey.as_str(), + SignedEventOverrides { + created_at: Some(draft.created_at + 1), + ..SignedEventOverrides::default() + }, + ); + + assert!(matches!( + sign_authorized_draft(&actor, &signer, &draft), + Err(RadrootsAuthorityError::SignedEventCreatedAtMismatch { .. }) + )); + } + + #[test] + fn signed_event_kind_mismatch_fails() { + let pubkey = hex_64('a'); + let draft = listing_draft(pubkey.as_str()); + let actor = seller_actor(pubkey.as_str()); + let signer = StaticSigner::with_overrides( + pubkey.as_str(), + SignedEventOverrides { + kind: Some(KIND_POST), + ..SignedEventOverrides::default() + }, + ); + + assert!(matches!( + sign_authorized_draft(&actor, &signer, &draft), + Err(RadrootsAuthorityError::SignedEventKindMismatch { + expected_kind: KIND_LISTING, + actual_kind: KIND_POST + }) + )); + } + + #[test] + fn signed_event_tags_mismatch_fails() { + let pubkey = hex_64('a'); + let draft = listing_draft(pubkey.as_str()); + let actor = seller_actor(pubkey.as_str()); + let signer = StaticSigner::with_overrides( + pubkey.as_str(), + SignedEventOverrides { + tags: Some(vec![vec!["d".to_owned(), "listing-b".to_owned()]]), + ..SignedEventOverrides::default() + }, + ); + + assert!(matches!( + sign_authorized_draft(&actor, &signer, &draft), + Err(RadrootsAuthorityError::SignedEventTagsMismatch { .. }) + )); + } + + #[test] + fn signed_event_content_mismatch_fails() { + let pubkey = hex_64('a'); + let draft = listing_draft(pubkey.as_str()); + let actor = seller_actor(pubkey.as_str()); + let signer = StaticSigner::with_overrides( + pubkey.as_str(), + SignedEventOverrides { + content: Some("{\"changed\":true}".to_owned()), + ..SignedEventOverrides::default() + }, + ); + + assert!(matches!( + sign_authorized_draft(&actor, &signer, &draft), + Err(RadrootsAuthorityError::SignedEventContentMismatch { .. }) + )); + } + + #[test] + fn signed_event_exactly_matching_draft_passes() { + let pubkey = hex_64('a'); + let draft = listing_draft(pubkey.as_str()); + let signed = signed_event_from_draft(&draft); + + validate_signed_event_matches_draft(&signed, &draft).expect("signed event matches draft"); + } + + #[test] + fn signed_event_computed_id_mismatch_fails() { + let pubkey = hex_64('a'); + let inconsistent_draft = RadrootsFrozenEventDraft { + contract_id: "radroots.listing.published.v1".to_owned(), + contract_registry_version: 1, + kind: KIND_LISTING, + created_at: 1_700_000_000, + tags: vec![vec!["d".to_owned(), "listing-a".to_owned()]], + content: "{}".to_owned(), + expected_pubkey: pubkey, + expected_event_id: hex_64('e'), + }; + let signed = signed_event_from_draft(&inconsistent_draft); + + assert!(matches!( + validate_signed_event_matches_draft(&signed, &inconsistent_draft), + Err(RadrootsAuthorityError::SignedEventComputedIdMismatch { .. }) + )); + } + + #[test] + fn sign_authorized_draft_calls_full_integrity_check() { + let pubkey = hex_64('a'); + let inconsistent_draft = RadrootsFrozenEventDraft { + contract_id: "radroots.listing.published.v1".to_owned(), + contract_registry_version: 1, + kind: KIND_LISTING, + created_at: 1_700_000_000, + tags: vec![vec!["d".to_owned(), "listing-a".to_owned()]], + content: "{}".to_owned(), + expected_pubkey: pubkey.clone(), + expected_event_id: hex_64('e'), + }; + let actor = seller_actor(pubkey.as_str()); + let signer = StaticSigner::new(pubkey.as_str()); + + assert!(matches!( + sign_authorized_draft(&actor, &signer, &inconsistent_draft), + Err(RadrootsAuthorityError::SignedEventComputedIdMismatch { .. }) + )); + } + + #[test] fn authorized_actor_and_signer_return_signed_event() { let pubkey = hex_64('a'); let draft = listing_draft(pubkey.as_str()); diff --git a/crates/authority/src/error.rs b/crates/authority/src/error.rs @@ -3,9 +3,9 @@ use thiserror::Error; #[cfg(not(feature = "std"))] -use alloc::string::String; +use alloc::{string::String, vec::Vec}; #[cfg(feature = "std")] -use std::string::String; +use std::{string::String, vec::Vec}; #[derive(Debug, Error, PartialEq, Eq)] pub enum RadrootsAuthorityError { @@ -55,6 +55,43 @@ pub enum RadrootsAuthorityError { actual_event_id: String, }, + #[error( + "signed event created_at mismatch: expected {expected_created_at}, got {actual_created_at}" + )] + SignedEventCreatedAtMismatch { + expected_created_at: u32, + actual_created_at: u32, + }, + + #[error("signed event kind mismatch: expected {expected_kind}, got {actual_kind}")] + SignedEventKindMismatch { + expected_kind: u32, + actual_kind: u32, + }, + + #[error("signed event tags mismatch: expected {expected_tags:?}, got {actual_tags:?}")] + SignedEventTagsMismatch { + expected_tags: Vec<Vec<String>>, + actual_tags: Vec<Vec<String>>, + }, + + #[error("signed event content mismatch")] + SignedEventContentMismatch { + expected_content: String, + actual_content: String, + }, + + #[error("signed event computed id could not be derived: {message}")] + SignedEventComputedIdInvalid { message: String }, + + #[error( + "signed event computed id mismatch: expected {expected_event_id}, computed {computed_event_id}" + )] + SignedEventComputedIdMismatch { + expected_event_id: String, + computed_event_id: String, + }, + #[error("signer error: {0}")] Signer(#[from] RadrootsSignerError), } diff --git a/crates/authority/src/lib.rs b/crates/authority/src/lib.rs @@ -18,7 +18,7 @@ pub use actor::{ }; pub use authorization::{ authorize_actor_for_contract, authorize_actor_for_draft, authorize_signer_for_draft, - sign_authorized_draft, + sign_authorized_draft, validate_signed_event_matches_draft, }; pub use error::{RadrootsAuthorityError, RadrootsSignerError}; #[cfg(feature = "local_signer")]