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:
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")]