lib

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

commit ef5af29985ea6239f5940cd6a0f9cf20d4ace94b
parent 5596c0d670682bba73d8c63b7015394266276a10
Author: triesap <tyson@radroots.org>
Date:   Mon, 13 Jul 2026 07:21:38 +0000

transport: name unchecked signed-event payloads

- rename signed-event payload constructors to explicit unchecked APIs
- add verified signed-event payload construction in runtime and Nostr crates
- route outbox delivery through verified construction before transport dispatch
- guard retired ambiguous constructors and extend payload tests

Diffstat:
Mcrates/runtime/src/transport.rs | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/transport/src/payload.rs | 6+++---
Mcrates/transport/tests/source_boundary.rs | 8++++++++
Mcrates/transport/tests/transport.rs | 16++++++++--------
Mcrates/transport_nostr/src/lib.rs | 2+-
Mcrates/transport_nostr/src/outbox.rs | 14++++++--------
Mcrates/transport_nostr/src/publish.rs | 30++++++++++++++++++++++++++++++
Mcrates/transport_nostr/tests/transport.rs | 55++++++++++++++++++++++++++++++++++++++++++++++---------
8 files changed, 185 insertions(+), 41 deletions(-)

diff --git a/crates/runtime/src/transport.rs b/crates/runtime/src/transport.rs @@ -60,12 +60,28 @@ pub enum RadrootsRuntimeTransportPayload { OpaqueBytes { label: String, bytes: Vec<u8> }, } +#[derive(serde::Deserialize)] +struct RuntimeSignedEventJsonWire { + id: String, + pubkey: String, + created_at: u32, + kind: u32, + tags: Vec<Vec<String>>, + content: String, + sig: String, +} + impl RadrootsRuntimeTransportPayload { + pub fn verified_signed_event_json( + event: &RadrootsSignedEvent, + ) -> Result<RadrootsTransportPayload, RadrootsTransportError> { + verify_signed_event_raw_json_matches_event(event)?; + RadrootsTransportPayload::unchecked_signed_event_json(&event.id, &event.raw_json) + } + pub fn transport_payload(&self) -> Result<RadrootsTransportPayload, RadrootsTransportError> { match self { - Self::SignedEvent(event) => { - RadrootsTransportPayload::signed_event_json(&event.id, &event.raw_json) - } + Self::SignedEvent(event) => Self::verified_signed_event_json(event), Self::OpaqueBytes { label, bytes } => { RadrootsTransportPayload::opaque_bytes(label, bytes) } @@ -73,6 +89,26 @@ impl RadrootsRuntimeTransportPayload { } } +fn verify_signed_event_raw_json_matches_event( + event: &RadrootsSignedEvent, +) -> Result<(), RadrootsTransportError> { + let wire: RuntimeSignedEventJsonWire = serde_json::from_str(event.raw_json.as_str()) + .map_err(|_| RadrootsTransportError::InvalidPayloadBytes)?; + if wire.id != event.id { + return Err(RadrootsTransportError::InvalidPayloadId); + } + if wire.pubkey != event.pubkey + || wire.created_at != event.created_at + || wire.kind != event.kind + || wire.tags != event.tags + || wire.content != event.content + || wire.sig != event.sig + { + return Err(RadrootsTransportError::InvalidPayloadBytes); + } + Ok(()) +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsRuntimeTransportDispatchRequest { pub request_id: String, @@ -634,11 +670,12 @@ mod tests { use radroots_transport::{ RadrootsTransport, RadrootsTransportCapabilities, RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus, - RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest, RadrootsTransportFuture, - RadrootsTransportImplementationState, RadrootsTransportKind, RadrootsTransportOutcome, - RadrootsTransportOutcomeKind, RadrootsTransportSatisfactionClass, - RadrootsTransportSatisfactionPolicy, RadrootsTransportStatus, RadrootsTransportTarget, - RadrootsTransportTargetReceipt, RadrootsTransportTargetSet, + RadrootsTransportError, RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest, + RadrootsTransportFuture, RadrootsTransportImplementationState, RadrootsTransportKind, + RadrootsTransportOutcome, RadrootsTransportOutcomeKind, RadrootsTransportPayload, + RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, + RadrootsTransportStatus, RadrootsTransportTarget, RadrootsTransportTargetReceipt, + RadrootsTransportTargetSet, }; #[cfg(feature = "transport-workers")] use std::sync::{Arc, Mutex}; @@ -763,20 +800,54 @@ mod tests { #[cfg(feature = "transport-workers")] fn signed_event() -> RadrootsSignedEvent { + let id = "d".repeat(64); + let pubkey = "e".repeat(64); + let sig = "f".repeat(128); RadrootsSignedEvent::new(RadrootsSignedEventParts { - id: "d".repeat(64), - pubkey: "e".repeat(64), + id: id.clone(), + pubkey: pubkey.clone(), created_at: 10, kind: 1, tags: Vec::new(), content: "hello".to_owned(), - sig: "f".repeat(128), - raw_json: "{\"id\":\"fixture\"}".to_owned(), + sig: sig.clone(), + raw_json: format!( + "{{\"id\":\"{id}\",\"pubkey\":\"{pubkey}\",\"created_at\":10,\"kind\":1,\"tags\":[],\"content\":\"hello\",\"sig\":\"{sig}\"}}" + ), }) .expect("signed event") } #[cfg(feature = "transport-workers")] + #[test] + fn runtime_verified_signed_event_payload_uses_structured_signed_event() { + let event = signed_event(); + let payload = RadrootsRuntimeTransportPayload::verified_signed_event_json(&event) + .expect("verified payload"); + let via_variant = RadrootsRuntimeTransportPayload::SignedEvent(event.clone()) + .transport_payload() + .expect("transport payload"); + let RadrootsTransportPayload::SignedEventJson { + event_id, raw_json, .. + } = payload.clone() + else { + panic!("signed event payload expected"); + }; + + assert_eq!(payload, via_variant); + assert_eq!(event_id, event.id); + assert_eq!(raw_json, event.raw_json); + + let mut mismatched = event; + mismatched.raw_json = "{}".to_owned(); + assert_eq!( + RadrootsRuntimeTransportPayload::verified_signed_event_json(&mismatched) + .expect_err("mismatched raw json"), + RadrootsTransportError::InvalidPayloadBytes + ); + } + + #[cfg(feature = "transport-workers")] struct RecordingInboundSink { expected_event_id: String, } diff --git a/crates/transport/src/payload.rs b/crates/transport/src/payload.rs @@ -24,7 +24,7 @@ pub enum RadrootsTransportPayload { } impl RadrootsTransportPayload { - pub fn signed_event_json( + pub fn unchecked_signed_event_json( event_id: impl AsRef<str>, raw_json: impl AsRef<str>, ) -> Result<Self, RadrootsTransportError> { @@ -38,12 +38,12 @@ impl RadrootsTransportPayload { }) } - pub fn signed_event_json_with_digest( + pub fn unchecked_signed_event_json_with_digest( event_id: impl AsRef<str>, raw_json: impl AsRef<str>, digest: impl AsRef<str>, ) -> Result<Self, RadrootsTransportError> { - let payload = Self::signed_event_json(event_id, raw_json)?; + let payload = Self::unchecked_signed_event_json(event_id, raw_json)?; validate_supplied_digest(payload.digest(), digest.as_ref())?; Ok(payload) } diff --git a/crates/transport/tests/source_boundary.rs b/crates/transport/tests/source_boundary.rs @@ -189,6 +189,14 @@ const FORBIDDEN_DELIVERY_PAYLOAD_CONCEPTS: &[ForbiddenConcept] = &[ pattern: "DigestOnly", reason: "runtime dispatch must not retain a digest-only payload path", }, + ForbiddenConcept { + pattern: "RadrootsTransportPayload::signed_event_json(", + reason: "signed-event payload construction must name unchecked validation explicitly", + }, + ForbiddenConcept { + pattern: "RadrootsTransportPayload::signed_event_json_with_digest(", + reason: "signed-event digest validation must name unchecked validation explicitly", + }, ]; #[test] diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs @@ -380,13 +380,13 @@ fn request_models_round_trip_with_serde() { } #[test] -fn payload_contract_derives_and_validates_digests() { +fn payload_contract_derives_and_validates_unchecked_signed_event_digests() { let event_id = "a".repeat(64); - let signed = RadrootsTransportPayload::signed_event_json( + let signed = RadrootsTransportPayload::unchecked_signed_event_json( event_id.as_str(), "{\"id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}", ) - .expect("signed event payload"); + .expect("unchecked signed event payload"); assert_eq!(signed.payload_kind(), "signed_event_json"); assert_eq!(signed.digest().len(), 64); assert!( @@ -396,12 +396,12 @@ fn payload_contract_derives_and_validates_digests() { .all(|byte| { byte.is_ascii_digit() || matches!(byte, b'a'..=b'f') }) ); assert_eq!( - RadrootsTransportPayload::signed_event_json_with_digest( + RadrootsTransportPayload::unchecked_signed_event_json_with_digest( event_id.as_str(), "{\"id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}", signed.digest(), ) - .expect("signed payload with digest"), + .expect("unchecked signed payload with digest"), signed ); @@ -430,14 +430,14 @@ fn payload_contract_derives_and_validates_digests() { } #[test] -fn payload_contract_rejects_invalid_ids_bytes_labels_and_digests() { +fn payload_contract_rejects_invalid_unchecked_signed_event_ids_bytes_labels_and_digests() { assert_eq!( - RadrootsTransportPayload::signed_event_json("A".repeat(64), "{}") + RadrootsTransportPayload::unchecked_signed_event_json("A".repeat(64), "{}") .expect_err("uppercase event id"), RadrootsTransportError::InvalidPayloadId ); assert_eq!( - RadrootsTransportPayload::signed_event_json("a".repeat(64), " [] ") + RadrootsTransportPayload::unchecked_signed_event_json("a".repeat(64), " [] ") .expect_err("non-object json"), RadrootsTransportError::InvalidPayloadBytes ); diff --git a/crates/transport_nostr/src/lib.rs b/crates/transport_nostr/src/lib.rs @@ -33,6 +33,6 @@ pub use publish::RadrootsNostrClientPublishAdapter; pub use publish::{ RadrootsMockRelayPublishAdapter, RadrootsNostrTransport, RadrootsRelayPublishAdapter, RadrootsRelayPublishReceipt, RadrootsRelayPublishRelayReceipt, RadrootsRelayPublishRequest, - publish_signed_event, + publish_signed_event, verified_signed_event_payload, }; pub use relay::{RadrootsRelayTargetSet, RadrootsRelayUrl, RadrootsRelayUrlPolicy}; diff --git a/crates/transport_nostr/src/outbox.rs b/crates/transport_nostr/src/outbox.rs @@ -4,6 +4,7 @@ use crate::{ RadrootsRelayOutcome, RadrootsRelayPublishAdapter, RadrootsRelayPublishReceipt, RadrootsRelayPublishRelayReceipt, RadrootsRelayPublishRequest, RadrootsRelayTargetSet, RadrootsRelayTransportError, RadrootsRelayUrlPolicy, publish_signed_event, + verified_signed_event_payload, }; use radroots_event::RadrootsEventEnvelope; use radroots_event::draft::RadrootsSignedEvent; @@ -18,9 +19,9 @@ use radroots_outbox::{ use radroots_transport::{ RadrootsTransport, RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus, RadrootsTransportError, RadrootsTransportKind, - RadrootsTransportOutcome, RadrootsTransportOutcomeKind, RadrootsTransportPayload, - RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, - RadrootsTransportTarget, RadrootsTransportTargetFingerprint, RadrootsTransportTargetSet, + RadrootsTransportOutcome, RadrootsTransportOutcomeKind, RadrootsTransportSatisfactionClass, + RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, + RadrootsTransportTargetFingerprint, RadrootsTransportTargetSet, }; #[derive(Clone, Debug, PartialEq, Eq)] @@ -285,11 +286,8 @@ where signed_event.id.as_str(), publishable.active_delivery_plan_id, ); - let payload = RadrootsTransportPayload::signed_event_json( - signed_event.id.clone(), - signed_event.raw_json.clone(), - ) - .map_err(transport_error_to_relay_error)?; + let payload = + verified_signed_event_payload(&signed_event).map_err(transport_error_to_relay_error)?; let delivery = transport .deliver( RadrootsTransportDeliveryRequest::new( diff --git a/crates/transport_nostr/src/publish.rs b/crates/transport_nostr/src/publish.rs @@ -109,6 +109,36 @@ pub trait RadrootsRelayPublishAdapter: Send + Sync { ) -> BoxFuture<'a, Result<Vec<RadrootsRelayPublishRelayReceipt>, RadrootsRelayTransportError>>; } +pub fn verified_signed_event_payload( + signed_event: &RadrootsSignedEvent, +) -> Result<RadrootsTransportPayload, RadrootsTransportError> { + verify_signed_event_raw_json_matches_event(signed_event)?; + RadrootsTransportPayload::unchecked_signed_event_json( + signed_event.id.as_str(), + signed_event.raw_json.as_str(), + ) +} + +fn verify_signed_event_raw_json_matches_event( + signed_event: &RadrootsSignedEvent, +) -> Result<(), RadrootsTransportError> { + let wire: SignedEventJsonWire = serde_json::from_str(signed_event.raw_json.as_str()) + .map_err(|_| RadrootsTransportError::InvalidPayloadBytes)?; + if wire.id != signed_event.id { + return Err(RadrootsTransportError::InvalidPayloadId); + } + if wire.pubkey != signed_event.pubkey + || wire.created_at != signed_event.created_at + || wire.kind != signed_event.kind + || wire.tags != signed_event.tags + || wire.content != signed_event.content + || wire.sig != signed_event.sig + { + return Err(RadrootsTransportError::InvalidPayloadBytes); + } + Ok(()) +} + impl<A> RadrootsRelayPublishAdapter for &A where A: RadrootsRelayPublishAdapter + ?Sized, diff --git a/crates/transport_nostr/tests/transport.rs b/crates/transport_nostr/tests/transport.rs @@ -31,7 +31,7 @@ use radroots_transport_nostr::{ RadrootsRelayPublishRelayReceipt, RadrootsRelayPublishRequest, RadrootsRelayTargetSet, RadrootsRelayTransportError, RadrootsRelayUrl, RadrootsRelayUrlPolicy, fetch_and_ingest_relay_events, fetch_relay_events, fetch_relay_events_blocking, - publish_claimed_outbox_event, publish_signed_event, + publish_claimed_outbox_event, publish_signed_event, verified_signed_event_payload, }; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; @@ -728,8 +728,11 @@ async fn nostr_transport_facade_delivers_signed_event_payloads() { let target = nostr_target(RELAY_PRIMARY_WSS); let request = RadrootsTransportDeliveryRequest::new( "facade-request-1", - RadrootsTransportPayload::signed_event_json(signed.id.clone(), signed.raw_json.clone()) - .expect("payload"), + RadrootsTransportPayload::unchecked_signed_event_json( + signed.id.clone(), + signed.raw_json.clone(), + ) + .expect("payload"), RadrootsTransportTargetSet::new(vec![target.clone()]).expect("targets"), RadrootsTransportSatisfactionPolicy::all_accepted(), ); @@ -754,6 +757,31 @@ async fn nostr_transport_facade_delivers_signed_event_payloads() { ); } +#[test] +fn verified_signed_event_payload_preserves_transport_payload_identity() { + let signed = signed_post("verified payload"); + let payload = verified_signed_event_payload(&signed).expect("verified payload"); + let RadrootsTransportPayload::SignedEventJson { + event_id, + raw_json, + digest, + } = payload + else { + panic!("signed event payload expected"); + }; + + assert_eq!(event_id, signed.id); + assert_eq!(raw_json, signed.raw_json); + assert_eq!(digest.len(), 64); + + let mut mismatched = signed; + mismatched.raw_json = "{}".to_owned(); + assert_eq!( + verified_signed_event_payload(&mismatched).expect_err("mismatched raw json"), + RadrootsTransportError::InvalidPayloadBytes + ); +} + #[tokio::test] async fn nostr_transport_facade_reports_fetch_as_unsupported_operation() { let transport = RadrootsNostrTransport::new(RadrootsMockRelayPublishAdapter::new()); @@ -791,8 +819,11 @@ async fn nostr_transport_facade_rejects_unsupported_payloads_and_targets() { let target_error = transport .deliver(RadrootsTransportDeliveryRequest::new( "facade-request-target", - RadrootsTransportPayload::signed_event_json(signed.id.clone(), signed.raw_json.clone()) - .expect("payload"), + RadrootsTransportPayload::unchecked_signed_event_json( + signed.id.clone(), + signed.raw_json.clone(), + ) + .expect("payload"), RadrootsTransportTargetSet::new(vec![non_nostr_target]).expect("targets"), RadrootsTransportSatisfactionPolicy::all_accepted(), )) @@ -814,8 +845,11 @@ async fn nostr_transport_facade_matches_canonical_equivalent_relay_receipts() { let receipt = transport .deliver(RadrootsTransportDeliveryRequest::new( "facade-canonical-receipt", - RadrootsTransportPayload::signed_event_json(signed.id.clone(), signed.raw_json.clone()) - .expect("payload"), + RadrootsTransportPayload::unchecked_signed_event_json( + signed.id.clone(), + signed.raw_json.clone(), + ) + .expect("payload"), RadrootsTransportTargetSet::new(vec![target.clone()]).expect("target set"), policy.clone(), )) @@ -859,8 +893,11 @@ async fn nostr_transport_facade_preserves_scoped_duplicate_target_metadata() { .expect("required targets"); let request = RadrootsTransportDeliveryRequest::new( "facade-request-scoped", - RadrootsTransportPayload::signed_event_json(signed.id.clone(), signed.raw_json.clone()) - .expect("payload"), + RadrootsTransportPayload::unchecked_signed_event_json( + signed.id.clone(), + signed.raw_json.clone(), + ) + .expect("payload"), RadrootsTransportTargetSet::new(vec![first.clone(), second.clone()]).expect("targets"), policy.clone(), );