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:
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(),
);