commit cb8d1c740c8f2fea8a87c76da87dc6e63f7c4a61
parent 85026d8ee06e55df8b3eb1ea03bff5f1f49b5554
Author: triesap <tyson@radroots.org>
Date: Wed, 5 Aug 2026 00:30:09 +0000
refactor(nostr): sign exact authored plans
- split typed body conversion from author and timestamp plan binding
- route every sealed typed builder through one shared plan-backed core
- construct external signer requests directly from exact immutable plans
- reject every signed field mismatch while preserving frozen Nostr wire bytes
Diffstat:
15 files changed, 583 insertions(+), 127 deletions(-)
diff --git a/crates/event_codec/src/authoring/typed.rs b/crates/event_codec/src/authoring/typed.rs
@@ -141,19 +141,98 @@ impl fmt::Display for AuthoredPlanError {
#[cfg(feature = "std")]
impl std::error::Error for AuthoredPlanError {}
+impl AuthoredEventBody {
+ #[cfg(feature = "json")]
+ pub fn from_profile(profile: &AuthoredProfile) -> Result<Self, AuthoredPlanError> {
+ let wire = authored_profile_to_wire_parts(profile).map_err(AuthoredPlanError::Profile)?;
+ build_typed_body("radroots.profile.metadata.v1", wire)
+ }
+
+ pub fn from_update(update: &AuthoredUpdate) -> Result<Self, AuthoredPlanError> {
+ build_typed_body(
+ "radroots.social.update.v1",
+ authored_update_to_wire_parts(update),
+ )
+ }
+
+ pub fn from_photo_update(photo: &AuthoredPhotoUpdate) -> Result<Self, AuthoredPlanError> {
+ build_typed_body(
+ "radroots.social.photo_update.v1",
+ authored_photo_update_to_wire_parts(photo),
+ )
+ }
+
+ pub fn from_ask(ask: &AuthoredAsk) -> Result<Self, AuthoredPlanError> {
+ build_typed_body("radroots.social.ask.v1", authored_ask_to_wire_parts(ask))
+ }
+
+ pub fn from_nip10_reply(reply: &AuthoredNip10Reply) -> Result<Self, AuthoredPlanError> {
+ build_typed_body(
+ "radroots.social.reply.v1",
+ authored_nip10_reply_to_wire_parts(reply),
+ )
+ }
+
+ pub fn from_nip09_deletion_request(
+ request: &AuthoredNip09DeletionRequest,
+ ) -> Result<Self, AuthoredPlanError> {
+ build_typed_body(
+ "radroots.social.deletion_request.v1",
+ authored_nip09_deletion_request_to_wire_parts(request),
+ )
+ }
+
+ pub fn from_nip22_comment(comment: &AuthoredNip22Comment) -> Result<Self, AuthoredPlanError> {
+ build_typed_body(
+ "radroots.social.comment.v1",
+ authored_nip22_comment_to_wire_parts(comment),
+ )
+ }
+
+ pub fn from_food_availability(
+ details: &FoodAvailabilityDetails,
+ created_at: u64,
+ ) -> Result<Self, AuthoredPlanError> {
+ let wire = authored_food_availability_to_wire_parts(details, created_at)
+ .map_err(AuthoredPlanError::FoodAvailability)?;
+ build_typed_body("radroots.food.availability.v1", wire)
+ }
+}
+
impl AuthoredEventPlan {
+ pub fn bind(
+ body: AuthoredEventBody,
+ created_at: u64,
+ expected_author: impl AsRef<str>,
+ ) -> Result<Self, AuthoredPlanError> {
+ let author = PublicKey::from_hex(expected_author.as_ref())
+ .map_err(AuthoredPlanError::InvalidAuthor)?;
+ let expected_event_id = compute_canonical_nip01_event_id(
+ &author.to_hex(),
+ created_at,
+ body.kind,
+ &body.tags,
+ &body.content,
+ )
+ .map_err(AuthoredPlanError::CanonicalEventId)?;
+ Ok(Self::from_validated_parts(
+ body,
+ author,
+ created_at,
+ expected_event_id,
+ ))
+ }
+
#[cfg(feature = "json")]
pub fn from_profile(
profile: &AuthoredProfile,
created_at: u64,
expected_author: impl AsRef<str>,
) -> Result<Self, AuthoredPlanError> {
- let wire = authored_profile_to_wire_parts(profile).map_err(AuthoredPlanError::Profile)?;
- build_typed_plan(
- "radroots.profile.metadata.v1",
- wire,
+ Self::bind(
+ AuthoredEventBody::from_profile(profile)?,
created_at,
- expected_author.as_ref(),
+ expected_author,
)
}
@@ -162,11 +241,10 @@ impl AuthoredEventPlan {
created_at: u64,
expected_author: impl AsRef<str>,
) -> Result<Self, AuthoredPlanError> {
- build_typed_plan(
- "radroots.social.update.v1",
- authored_update_to_wire_parts(update),
+ Self::bind(
+ AuthoredEventBody::from_update(update)?,
created_at,
- expected_author.as_ref(),
+ expected_author,
)
}
@@ -175,11 +253,10 @@ impl AuthoredEventPlan {
created_at: u64,
expected_author: impl AsRef<str>,
) -> Result<Self, AuthoredPlanError> {
- build_typed_plan(
- "radroots.social.photo_update.v1",
- authored_photo_update_to_wire_parts(photo),
+ Self::bind(
+ AuthoredEventBody::from_photo_update(photo)?,
created_at,
- expected_author.as_ref(),
+ expected_author,
)
}
@@ -188,11 +265,10 @@ impl AuthoredEventPlan {
created_at: u64,
expected_author: impl AsRef<str>,
) -> Result<Self, AuthoredPlanError> {
- build_typed_plan(
- "radroots.social.ask.v1",
- authored_ask_to_wire_parts(ask),
+ Self::bind(
+ AuthoredEventBody::from_ask(ask)?,
created_at,
- expected_author.as_ref(),
+ expected_author,
)
}
@@ -201,11 +277,10 @@ impl AuthoredEventPlan {
created_at: u64,
expected_author: impl AsRef<str>,
) -> Result<Self, AuthoredPlanError> {
- build_typed_plan(
- "radroots.social.reply.v1",
- authored_nip10_reply_to_wire_parts(reply),
+ Self::bind(
+ AuthoredEventBody::from_nip10_reply(reply)?,
created_at,
- expected_author.as_ref(),
+ expected_author,
)
}
@@ -214,11 +289,10 @@ impl AuthoredEventPlan {
created_at: u64,
expected_author: impl AsRef<str>,
) -> Result<Self, AuthoredPlanError> {
- build_typed_plan(
- "radroots.social.deletion_request.v1",
- authored_nip09_deletion_request_to_wire_parts(request),
+ Self::bind(
+ AuthoredEventBody::from_nip09_deletion_request(request)?,
created_at,
- expected_author.as_ref(),
+ expected_author,
)
}
@@ -227,11 +301,10 @@ impl AuthoredEventPlan {
created_at: u64,
expected_author: impl AsRef<str>,
) -> Result<Self, AuthoredPlanError> {
- build_typed_plan(
- "radroots.social.comment.v1",
- authored_nip22_comment_to_wire_parts(comment),
+ Self::bind(
+ AuthoredEventBody::from_nip22_comment(comment)?,
created_at,
- expected_author.as_ref(),
+ expected_author,
)
}
@@ -240,23 +313,18 @@ impl AuthoredEventPlan {
created_at: u64,
expected_author: impl AsRef<str>,
) -> Result<Self, AuthoredPlanError> {
- let wire = authored_food_availability_to_wire_parts(details, created_at)
- .map_err(AuthoredPlanError::FoodAvailability)?;
- build_typed_plan(
- "radroots.food.availability.v1",
- wire,
+ Self::bind(
+ AuthoredEventBody::from_food_availability(details, created_at)?,
created_at,
- expected_author.as_ref(),
+ expected_author,
)
}
}
-fn build_typed_plan(
+fn build_typed_body(
contract_id: &str,
wire: Nip01EventWireParts,
- created_at: u64,
- expected_author: &str,
-) -> Result<AuthoredEventPlan, AuthoredPlanError> {
+) -> Result<AuthoredEventBody, AuthoredPlanError> {
let contract =
ContractKey::current(contract_id).map_err(AuthoredPlanError::ContractIdentity)?;
let definition = contract.contract();
@@ -278,27 +346,12 @@ fn build_typed_plan(
});
}
EventTags::new(wire.tags.clone()).map_err(AuthoredPlanError::Envelope)?;
- let author = PublicKey::from_hex(expected_author).map_err(AuthoredPlanError::InvalidAuthor)?;
- let expected_event_id = compute_canonical_nip01_event_id(
- &author.to_hex(),
- created_at,
- wire.kind,
- &wire.tags,
- &wire.content,
- )
- .map_err(AuthoredPlanError::CanonicalEventId)?;
- let body = AuthoredEventBody {
+ Ok(AuthoredEventBody {
contract,
kind: wire.kind,
tags: wire.tags,
content: wire.content,
- };
- Ok(AuthoredEventPlan::from_validated_parts(
- body,
- author,
- created_at,
- expected_event_id,
- ))
+ })
}
#[cfg(feature = "json")]
diff --git a/crates/nostr/src/error.rs b/crates/nostr/src/error.rs
@@ -62,6 +62,9 @@ pub enum Error {
#[error("External signing event is invalid: {0}")]
ExternalSigningEventInvalid(#[cfg_attr(feature = "std", source)] nostr::event::Error),
+ #[error("External signing result does not match authored plan field `{field}`")]
+ ExternalSigningPlanMismatch { field: &'static str },
+
#[error("Event error: {0}")]
EventError(#[cfg_attr(feature = "std", source)] nostr::event::Error),
@@ -90,6 +93,10 @@ pub enum Error {
),
#[cfg(feature = "events")]
+ #[error("Authored plan error: {0}")]
+ AuthoredPlan(#[from] radroots_event_codec::authoring::AuthoredPlanError),
+
+ #[cfg(feature = "events")]
#[error("Signed event error: {0}")]
SignedEvent(#[from] radroots_event::draft::SignedEventError),
diff --git a/crates/nostr/src/events/comment.rs b/crates/nostr/src/events/comment.rs
@@ -2,13 +2,14 @@
use crate::{
error::Error,
+ events::sealed::SealedBuilderCore,
types::{
- RadrootsNostrEvent, RadrootsNostrEventBuilderUnchecked, RadrootsNostrKeys,
+ ExternalSigningRequest, RadrootsNostrEvent, RadrootsNostrKeys, RadrootsNostrPublicKey,
RadrootsNostrTimestamp,
},
};
-use radroots_event::{post::comment::AuthoredNip22Comment, wire::Nip01EventWireParts};
-use radroots_event_codec::encode::comment::authored_nip22_comment_to_wire_parts;
+use radroots_event::post::comment::AuthoredNip22Comment;
+use radroots_event_codec::authoring::AuthoredEventBody;
/// A sealed builder for a validated strict NIP-22 Comment.
///
@@ -23,7 +24,7 @@ use radroots_event_codec::encode::comment::authored_nip22_comment_to_wire_parts;
/// ```
#[must_use = "NIP-22 Comment builders must be signed or published"]
pub struct Nip22CommentBuilder {
- inner: RadrootsNostrEventBuilderUnchecked,
+ inner: SealedBuilderCore,
}
impl Nip22CommentBuilder {
@@ -33,19 +34,23 @@ impl Nip22CommentBuilder {
}
pub fn sign_with_keys(self, keys: &RadrootsNostrKeys) -> Result<RadrootsNostrEvent, Error> {
- Ok(self.inner.sign_with_keys(keys)?)
+ self.inner.sign_with_keys(keys)
+ }
+
+ pub fn into_external_signing_request(
+ self,
+ public_key: RadrootsNostrPublicKey,
+ ) -> Result<ExternalSigningRequest, Error> {
+ self.inner.into_external_signing_request(public_key)
}
}
pub fn build_nip22_comment_event(
comment: &AuthoredNip22Comment,
) -> Result<Nip22CommentBuilder, Error> {
- builder_from_wire_parts(authored_nip22_comment_to_wire_parts(comment))
-}
-
-fn builder_from_wire_parts(parts: Nip01EventWireParts) -> Result<Nip22CommentBuilder, Error> {
- let inner = crate::events::build_event_unchecked(parts.kind, parts.content, parts.tags)?;
- Ok(Nip22CommentBuilder { inner })
+ Ok(Nip22CommentBuilder {
+ inner: SealedBuilderCore::new(AuthoredEventBody::from_nip22_comment(comment)?),
+ })
}
#[cfg(test)]
diff --git a/crates/nostr/src/events/deletion.rs b/crates/nostr/src/events/deletion.rs
@@ -2,13 +2,14 @@
use crate::{
error::Error,
+ events::sealed::SealedBuilderCore,
types::{
- RadrootsNostrEvent, RadrootsNostrEventBuilderUnchecked, RadrootsNostrKeys,
+ ExternalSigningRequest, RadrootsNostrEvent, RadrootsNostrKeys, RadrootsNostrPublicKey,
RadrootsNostrTimestamp,
},
};
-use radroots_event::{post::deletion::AuthoredNip09DeletionRequest, wire::Nip01EventWireParts};
-use radroots_event_codec::encode::deletion::authored_nip09_deletion_request_to_wire_parts;
+use radroots_event::post::deletion::AuthoredNip09DeletionRequest;
+use radroots_event_codec::authoring::AuthoredEventBody;
/// A sealed builder for a validated NIP-09 deletion request.
///
@@ -36,7 +37,7 @@ use radroots_event_codec::encode::deletion::authored_nip09_deletion_request_to_w
/// ```
#[must_use = "NIP-09 deletion request builders must be signed or published"]
pub struct Nip09DeletionRequestBuilder {
- inner: RadrootsNostrEventBuilderUnchecked,
+ inner: SealedBuilderCore,
}
impl Nip09DeletionRequestBuilder {
@@ -46,19 +47,21 @@ impl Nip09DeletionRequestBuilder {
}
pub fn sign_with_keys(self, keys: &RadrootsNostrKeys) -> Result<RadrootsNostrEvent, Error> {
- Ok(self.inner.sign_with_keys(keys)?)
+ self.inner.sign_with_keys(keys)
+ }
+
+ pub fn into_external_signing_request(
+ self,
+ public_key: RadrootsNostrPublicKey,
+ ) -> Result<ExternalSigningRequest, Error> {
+ self.inner.into_external_signing_request(public_key)
}
}
pub fn build_nip09_deletion_request_event(
request: &AuthoredNip09DeletionRequest,
) -> Result<Nip09DeletionRequestBuilder, Error> {
- builder_from_wire_parts(authored_nip09_deletion_request_to_wire_parts(request))
-}
-
-fn builder_from_wire_parts(
- parts: Nip01EventWireParts,
-) -> Result<Nip09DeletionRequestBuilder, Error> {
- let inner = crate::events::build_event_unchecked(parts.kind, parts.content, parts.tags)?;
- Ok(Nip09DeletionRequestBuilder { inner })
+ Ok(Nip09DeletionRequestBuilder {
+ inner: SealedBuilderCore::new(AuthoredEventBody::from_nip09_deletion_request(request)?),
+ })
}
diff --git a/crates/nostr/src/events/food_availability.rs b/crates/nostr/src/events/food_availability.rs
@@ -2,13 +2,14 @@
use crate::{
error::Error,
+ events::sealed::SealedBuilderCore,
types::{
- RadrootsNostrEvent, RadrootsNostrEventBuilderUnchecked, RadrootsNostrKeys,
+ ExternalSigningRequest, RadrootsNostrEvent, RadrootsNostrKeys, RadrootsNostrPublicKey,
RadrootsNostrTimestamp,
},
};
use radroots_event::food::availability::FoodAvailabilityDetails;
-use radroots_event_codec::encode::food_availability::authored_food_availability_to_wire_parts;
+use radroots_event_codec::authoring::{AuthoredEventBody, AuthoredPlanError};
/// A sealed builder for a validated focused FoodAvailability event.
///
@@ -34,7 +35,7 @@ use radroots_event_codec::encode::food_availability::authored_food_availability_
/// ```
#[must_use = "FoodAvailability event builders must be signed or published"]
pub struct FoodAvailabilityBuilder {
- inner: RadrootsNostrEventBuilderUnchecked,
+ inner: SealedBuilderCore,
}
impl FoodAvailabilityBuilder {
@@ -42,7 +43,14 @@ impl FoodAvailabilityBuilder {
///
/// Media-bearing callers must prove successful BUD-02 upload first.
pub fn sign_with_keys(self, keys: &RadrootsNostrKeys) -> Result<RadrootsNostrEvent, Error> {
- Ok(self.inner.sign_with_keys(keys)?)
+ self.inner.sign_with_keys(keys)
+ }
+
+ pub fn into_external_signing_request(
+ self,
+ public_key: RadrootsNostrPublicKey,
+ ) -> Result<ExternalSigningRequest, Error> {
+ self.inner.into_external_signing_request(public_key)
}
}
@@ -53,8 +61,12 @@ pub fn build_food_availability_event(
details: &FoodAvailabilityDetails,
created_at: RadrootsNostrTimestamp,
) -> Result<FoodAvailabilityBuilder, Error> {
- let parts = authored_food_availability_to_wire_parts(details, created_at.as_secs())?;
- let inner = super::build_event_unchecked(parts.kind, parts.content, parts.tags)?
- .custom_created_at(created_at);
+ let body = AuthoredEventBody::from_food_availability(details, created_at.as_secs()).map_err(
+ |error| match error {
+ AuthoredPlanError::FoodAvailability(error) => Error::FoodAvailabilityEncode(error),
+ error => Error::AuthoredPlan(error),
+ },
+ )?;
+ let inner = SealedBuilderCore::at(body, created_at);
Ok(FoodAvailabilityBuilder { inner })
}
diff --git a/crates/nostr/src/events/metadata.rs b/crates/nostr/src/events/metadata.rs
@@ -3,13 +3,16 @@
#[cfg(feature = "events")]
use crate::error::Error;
#[cfg(feature = "events")]
-use crate::types::RadrootsNostrEvent;
+use crate::events::sealed::SealedBuilderCore;
#[cfg(feature = "events")]
-use crate::types::{RadrootsNostrEventBuilderUnchecked, RadrootsNostrKeys, RadrootsNostrTimestamp};
+use crate::types::{
+ ExternalSigningRequest, RadrootsNostrEvent, RadrootsNostrKeys, RadrootsNostrPublicKey,
+ RadrootsNostrTimestamp,
+};
#[cfg(feature = "events")]
use radroots_event::profile::AuthoredProfile;
#[cfg(feature = "events")]
-use radroots_event_codec::encode::profile::authored_profile_to_wire_parts;
+use radroots_event_codec::authoring::{AuthoredEventBody, AuthoredPlanError};
/// A sealed builder for a validated kind-0 Profile replacement snapshot.
///
@@ -19,7 +22,7 @@ use radroots_event_codec::encode::profile::authored_profile_to_wire_parts;
#[cfg(feature = "events")]
#[must_use = "Profile event builders must be signed or published"]
pub struct ProfileBuilder {
- inner: RadrootsNostrEventBuilderUnchecked,
+ inner: SealedBuilderCore,
}
#[cfg(feature = "events")]
@@ -32,14 +35,25 @@ impl ProfileBuilder {
/// Signs the validated Profile directly with local keys.
pub fn sign_with_keys(self, keys: &RadrootsNostrKeys) -> Result<RadrootsNostrEvent, Error> {
- Ok(self.inner.sign_with_keys(keys)?)
+ self.inner.sign_with_keys(keys)
+ }
+
+ /// Finalizes the exact authored plan for an external signer.
+ pub fn into_external_signing_request(
+ self,
+ public_key: RadrootsNostrPublicKey,
+ ) -> Result<ExternalSigningRequest, Error> {
+ self.inner.into_external_signing_request(public_key)
}
}
/// Builds a sealed kind-0 event from the strict authored Profile contract.
#[cfg(feature = "events")]
pub fn build_profile_event(profile: &AuthoredProfile) -> Result<ProfileBuilder, Error> {
- let parts = authored_profile_to_wire_parts(profile)?;
- let inner = crate::events::build_event_unchecked(parts.kind, parts.content, parts.tags)?;
+ let body = AuthoredEventBody::from_profile(profile).map_err(|error| match error {
+ AuthoredPlanError::Profile(error) => Error::ProfileEncode(error),
+ error => Error::AuthoredPlan(error),
+ })?;
+ let inner = SealedBuilderCore::new(body);
Ok(ProfileBuilder { inner })
}
diff --git a/crates/nostr/src/events/mod.rs b/crates/nostr/src/events/mod.rs
@@ -17,6 +17,8 @@ pub mod metadata;
pub mod post;
#[cfg(feature = "events")]
pub mod reply;
+#[cfg(feature = "events")]
+mod sealed;
extern crate alloc;
#[cfg(any(feature = "events", test))]
diff --git a/crates/nostr/src/events/post.rs b/crates/nostr/src/events/post.rs
@@ -3,19 +3,17 @@
#[cfg(feature = "events")]
use crate::error::Error;
#[cfg(feature = "events")]
-use crate::types::RadrootsNostrEventBuilderUnchecked;
+use crate::events::sealed::SealedBuilderCore;
#[cfg(feature = "events")]
-use crate::types::{RadrootsNostrEvent, RadrootsNostrKeys};
+use crate::types::{
+ ExternalSigningRequest, RadrootsNostrEvent, RadrootsNostrKeys, RadrootsNostrPublicKey,
+};
use crate::types::{RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrTimestamp};
#[cfg(feature = "events")]
use radroots_event::post::{AuthoredAsk, AuthoredPhotoUpdate, AuthoredUpdate};
#[cfg(feature = "events")]
-use radroots_event::wire::Nip01EventWireParts;
-#[cfg(feature = "events")]
-use radroots_event_codec::encode::post::{
- authored_ask_to_wire_parts, authored_photo_update_to_wire_parts, authored_update_to_wire_parts,
-};
+use radroots_event_codec::authoring::AuthoredEventBody;
/// A sealed builder for a validated Radroots root post profile.
///
@@ -24,7 +22,7 @@ use radroots_event_codec::encode::post::{
#[cfg(feature = "events")]
#[must_use = "post event builders must be signed or published"]
pub struct PostBuilder {
- inner: RadrootsNostrEventBuilderUnchecked,
+ inner: SealedBuilderCore,
}
#[cfg(feature = "events")]
@@ -37,23 +35,33 @@ impl PostBuilder {
/// Signs the validated post directly with local keys.
pub fn sign_with_keys(self, keys: &RadrootsNostrKeys) -> Result<RadrootsNostrEvent, Error> {
- Ok(self.inner.sign_with_keys(keys)?)
+ self.inner.sign_with_keys(keys)
+ }
+
+ /// Finalizes the exact authored plan for an external signer.
+ pub fn into_external_signing_request(
+ self,
+ public_key: RadrootsNostrPublicKey,
+ ) -> Result<ExternalSigningRequest, Error> {
+ self.inner.into_external_signing_request(public_key)
}
}
#[cfg(feature = "events")]
pub fn build_update_event(update: &AuthoredUpdate) -> Result<PostBuilder, Error> {
- builder_from_wire_parts(authored_update_to_wire_parts(update))
+ Ok(builder_from_body(AuthoredEventBody::from_update(update)?))
}
#[cfg(feature = "events")]
pub fn build_photo_update_event(photo: &AuthoredPhotoUpdate) -> Result<PostBuilder, Error> {
- builder_from_wire_parts(authored_photo_update_to_wire_parts(photo))
+ Ok(builder_from_body(AuthoredEventBody::from_photo_update(
+ photo,
+ )?))
}
#[cfg(feature = "events")]
pub fn build_ask_event(ask: &AuthoredAsk) -> Result<PostBuilder, Error> {
- builder_from_wire_parts(authored_ask_to_wire_parts(ask))
+ Ok(builder_from_body(AuthoredEventBody::from_ask(ask)?))
}
pub fn post_events_filter(limit: Option<u16>, since_unix: Option<u64>) -> RadrootsNostrFilter {
@@ -68,7 +76,8 @@ pub fn post_events_filter(limit: Option<u16>, since_unix: Option<u64>) -> Radroo
}
#[cfg(feature = "events")]
-fn builder_from_wire_parts(parts: Nip01EventWireParts) -> Result<PostBuilder, Error> {
- let inner = crate::events::build_event_unchecked(parts.kind, parts.content, parts.tags)?;
- Ok(PostBuilder { inner })
+fn builder_from_body(body: AuthoredEventBody) -> PostBuilder {
+ PostBuilder {
+ inner: SealedBuilderCore::new(body),
+ }
}
diff --git a/crates/nostr/src/events/reply.rs b/crates/nostr/src/events/reply.rs
@@ -2,13 +2,14 @@
use crate::{
error::Error,
+ events::sealed::SealedBuilderCore,
types::{
- RadrootsNostrEvent, RadrootsNostrEventBuilderUnchecked, RadrootsNostrKeys,
+ ExternalSigningRequest, RadrootsNostrEvent, RadrootsNostrKeys, RadrootsNostrPublicKey,
RadrootsNostrTimestamp,
},
};
-use radroots_event::{post::reply::AuthoredNip10Reply, wire::Nip01EventWireParts};
-use radroots_event_codec::encode::reply::authored_nip10_reply_to_wire_parts;
+use radroots_event::post::reply::AuthoredNip10Reply;
+use radroots_event_codec::authoring::AuthoredEventBody;
/// A sealed builder for a validated strict marked NIP-10 Reply.
///
@@ -23,7 +24,7 @@ use radroots_event_codec::encode::reply::authored_nip10_reply_to_wire_parts;
/// ```
#[must_use = "NIP-10 Reply builders must be signed or published"]
pub struct Nip10ReplyBuilder {
- inner: RadrootsNostrEventBuilderUnchecked,
+ inner: SealedBuilderCore,
}
impl Nip10ReplyBuilder {
@@ -33,16 +34,19 @@ impl Nip10ReplyBuilder {
}
pub fn sign_with_keys(self, keys: &RadrootsNostrKeys) -> Result<RadrootsNostrEvent, Error> {
- Ok(self.inner.sign_with_keys(keys)?)
+ self.inner.sign_with_keys(keys)
}
-}
-pub fn build_nip10_reply_event(reply: &AuthoredNip10Reply) -> Result<Nip10ReplyBuilder, Error> {
- let parts = authored_nip10_reply_to_wire_parts(reply);
- builder_from_wire_parts(parts)
+ pub fn into_external_signing_request(
+ self,
+ public_key: RadrootsNostrPublicKey,
+ ) -> Result<ExternalSigningRequest, Error> {
+ self.inner.into_external_signing_request(public_key)
+ }
}
-fn builder_from_wire_parts(parts: Nip01EventWireParts) -> Result<Nip10ReplyBuilder, Error> {
- let inner = crate::events::build_event_unchecked(parts.kind, parts.content, parts.tags)?;
- Ok(Nip10ReplyBuilder { inner })
+pub fn build_nip10_reply_event(reply: &AuthoredNip10Reply) -> Result<Nip10ReplyBuilder, Error> {
+ Ok(Nip10ReplyBuilder {
+ inner: SealedBuilderCore::new(AuthoredEventBody::from_nip10_reply(reply)?),
+ })
}
diff --git a/crates/nostr/src/events/sealed.rs b/crates/nostr/src/events/sealed.rs
@@ -0,0 +1,58 @@
+//! Shared exact core for contract-specific sealed Nostr builders.
+
+#![forbid(unsafe_code)]
+
+use crate::{
+ error::Error,
+ types::{
+ ExternalSigningRequest, RadrootsNostrEvent, RadrootsNostrKeys, RadrootsNostrPublicKey,
+ RadrootsNostrTimestamp,
+ },
+};
+use radroots_event_codec::authoring::{AuthoredEventBody, AuthoredEventPlan};
+
+pub(crate) struct SealedBuilderCore {
+ body: AuthoredEventBody,
+ created_at: Option<RadrootsNostrTimestamp>,
+}
+
+impl SealedBuilderCore {
+ pub(crate) const fn new(body: AuthoredEventBody) -> Self {
+ Self {
+ body,
+ created_at: None,
+ }
+ }
+
+ pub(crate) const fn at(body: AuthoredEventBody, created_at: RadrootsNostrTimestamp) -> Self {
+ Self {
+ body,
+ created_at: Some(created_at),
+ }
+ }
+
+ pub(crate) const fn custom_created_at(mut self, created_at: RadrootsNostrTimestamp) -> Self {
+ self.created_at = Some(created_at);
+ self
+ }
+
+ pub(crate) fn sign_with_keys(
+ self,
+ keys: &RadrootsNostrKeys,
+ ) -> Result<RadrootsNostrEvent, Error> {
+ self.into_external_signing_request(keys.public_key())?
+ .sign_with_keys(keys)
+ }
+
+ pub(crate) fn into_external_signing_request(
+ self,
+ public_key: RadrootsNostrPublicKey,
+ ) -> Result<ExternalSigningRequest, Error> {
+ let created_at = self
+ .created_at
+ .unwrap_or_else(RadrootsNostrTimestamp::now)
+ .as_secs();
+ let plan = AuthoredEventPlan::bind(self.body, created_at, public_key.to_hex())?;
+ ExternalSigningRequest::from_authored_plan(plan)
+ }
+}
diff --git a/crates/nostr/src/lib.rs b/crates/nostr/src/lib.rs
@@ -47,3 +47,5 @@ mod draft_signing;
mod event_convert;
#[cfg(feature = "events")]
mod event_verify;
+#[cfg(feature = "events")]
+mod plan_signing;
diff --git a/crates/nostr/src/plan_signing.rs b/crates/nostr/src/plan_signing.rs
@@ -0,0 +1,112 @@
+//! Exact Nostr unsigned-event construction and completion for authored plans.
+
+#![forbid(unsafe_code)]
+
+use crate::{
+ error::Error,
+ types::{
+ RadrootsNostrEvent, RadrootsNostrEventId, RadrootsNostrKind, RadrootsNostrPublicKey,
+ RadrootsNostrTag, RadrootsNostrTimestamp,
+ },
+};
+use radroots_event_codec::authoring::AuthoredEventPlan;
+
+pub(crate) fn unsigned_event_from_plan(
+ plan: &AuthoredEventPlan,
+) -> Result<nostr::UnsignedEvent, Error> {
+ let kind = u16::try_from(plan.body().kind()).map_err(|_| Error::KindOutOfRange {
+ kind: plan.body().kind(),
+ max: u16::MAX,
+ })?;
+ let tags = plan
+ .body()
+ .tags()
+ .iter()
+ .cloned()
+ .map(RadrootsNostrTag::parse)
+ .collect::<Result<alloc::vec::Vec<_>, _>>()
+ .map_err(|_| Error::TagConversion)?;
+ let expected_event_id = RadrootsNostrEventId::from_slice(plan.expected_event_id().as_bytes())
+ .map_err(|_| Error::EventConversion {
+ field: "expected_event_id",
+ })?;
+ let expected_public_key = RadrootsNostrPublicKey::from_slice(plan.author().as_bytes())
+ .map_err(|_| Error::EventConversion { field: "author" })?;
+
+ let unsigned = nostr::UnsignedEvent {
+ id: Some(expected_event_id),
+ pubkey: expected_public_key,
+ created_at: RadrootsNostrTimestamp::from_secs(plan.created_at()),
+ kind: RadrootsNostrKind::Custom(kind),
+ tags: nostr::Tags::from_list(tags),
+ content: plan.body().content().into(),
+ };
+ validate_unsigned_event_matches_plan(&unsigned, plan)?;
+ Ok(unsigned)
+}
+
+pub(crate) fn validate_signed_event_matches_plan(
+ event: &RadrootsNostrEvent,
+ plan: &AuthoredEventPlan,
+) -> Result<(), Error> {
+ validate_exact_fields(
+ event.pubkey,
+ event.created_at,
+ event.kind,
+ &event.tags,
+ &event.content,
+ plan,
+ )
+}
+
+fn validate_unsigned_event_matches_plan(
+ event: &nostr::UnsignedEvent,
+ plan: &AuthoredEventPlan,
+) -> Result<(), Error> {
+ if event.id.map(|id| id.to_bytes()) != Some(*plan.expected_event_id().as_bytes()) {
+ return Err(Error::ExternalSigningPlanMismatch {
+ field: "expected_event_id",
+ });
+ }
+ validate_exact_fields(
+ event.pubkey,
+ event.created_at,
+ event.kind,
+ &event.tags,
+ &event.content,
+ plan,
+ )
+}
+
+fn validate_exact_fields(
+ author: RadrootsNostrPublicKey,
+ created_at: RadrootsNostrTimestamp,
+ kind: RadrootsNostrKind,
+ tags: &nostr::Tags,
+ content: &str,
+ plan: &AuthoredEventPlan,
+) -> Result<(), Error> {
+ if author.to_bytes() != *plan.author().as_bytes() {
+ return Err(Error::ExternalSigningPlanMismatch { field: "author" });
+ }
+ if created_at.as_secs() != plan.created_at() {
+ return Err(Error::ExternalSigningPlanMismatch {
+ field: "created_at",
+ });
+ }
+ if u32::from(kind.as_u16()) != plan.body().kind() {
+ return Err(Error::ExternalSigningPlanMismatch { field: "kind" });
+ }
+ if tags.len() != plan.body().tags().len()
+ || tags
+ .iter()
+ .zip(plan.body().tags())
+ .any(|(actual, expected)| actual.as_slice() != expected)
+ {
+ return Err(Error::ExternalSigningPlanMismatch { field: "tags" });
+ }
+ if content != plan.body().content() {
+ return Err(Error::ExternalSigningPlanMismatch { field: "content" });
+ }
+ Ok(())
+}
diff --git a/crates/nostr/src/types.rs b/crates/nostr/src/types.rs
@@ -12,6 +12,8 @@ use crate::error::Error;
use radroots_event::listing::classified::{
ClassifiedListingPartition, classify_classified_listing_marker_names,
};
+#[cfg(feature = "events")]
+use radroots_event_codec::authoring::AuthoredEventPlan;
#[cfg(feature = "events")]
pub(crate) use crate::event::Event as RadrootsNostrEvent;
@@ -35,12 +37,12 @@ pub(crate) type RadrootsNostrKeys = nostr::Keys;
pub(crate) type RadrootsNostrPublicKey = nostr::PublicKey;
pub(crate) type RadrootsNostrRelayUrl = nostr::RelayUrl;
-/// A checked generic event prepared for an external signer.
+/// A checked event prepared for an external signer.
///
-/// The request is created only after generic authoring policy succeeds. It
-/// serializes as the standard Nostr unsigned-event object expected by signer
-/// helpers, but it exposes no raw unsigned event, mutation, or unchecked
-/// deserialization boundary.
+/// Generic requests are created only after generic authoring policy succeeds;
+/// authored requests retain their immutable plan. Both serialize as the
+/// standard Nostr unsigned-event object expected by signer helpers, but expose
+/// no raw unsigned event, mutation, or unchecked deserialization boundary.
///
/// ```compile_fail
/// use radroots_nostr::event::ExternalSigningRequest;
@@ -54,10 +56,28 @@ pub struct ExternalSigningRequest {
unsigned_event: nostr::UnsignedEvent,
expected_event_id: RadrootsNostrEventId,
expected_public_key: RadrootsNostrPublicKey,
+ authored_plan: Option<AuthoredEventPlan>,
}
#[cfg(feature = "events")]
impl ExternalSigningRequest {
+ /// Creates the exact standard unsigned request committed by an authored plan.
+ pub fn from_authored_plan(plan: AuthoredEventPlan) -> Result<Self, Error> {
+ let unsigned_event = crate::plan_signing::unsigned_event_from_plan(&plan)?;
+ let expected_event_id = unsigned_event
+ .id
+ .ok_or(Error::ExternalSigningPlanMismatch {
+ field: "expected_event_id",
+ })?;
+ let expected_public_key = unsigned_event.pubkey;
+ Ok(Self {
+ unsigned_event,
+ expected_event_id,
+ expected_public_key,
+ authored_plan: Some(plan),
+ })
+ }
+
pub fn expected_event_id(&self) -> RadrootsNostrEventId {
self.expected_event_id
}
@@ -66,6 +86,14 @@ impl ExternalSigningRequest {
self.expected_public_key
}
+ /// Returns the immutable authored plan for typed requests.
+ ///
+ /// Low-level generic interoperability requests have no product plan and
+ /// therefore return `None`.
+ pub const fn authored_plan(&self) -> Option<&AuthoredEventPlan> {
+ self.authored_plan.as_ref()
+ }
+
/// Accepts an external signing result only when it is the exact requested
/// event and its NIP-01 identifier and signature are valid.
#[cfg(feature = "std")]
@@ -82,12 +110,18 @@ impl ExternalSigningRequest {
actual: event.id,
});
}
+ if let Some(plan) = &self.authored_plan {
+ crate::plan_signing::validate_signed_event_matches_plan(&event, plan)?;
+ }
event.verify().map_err(Error::ExternalSigningEventInvalid)?;
Ok(event)
}
#[cfg(feature = "std")]
- fn sign_with_keys(self, keys: &RadrootsNostrKeys) -> Result<RadrootsNostrEvent, Error> {
+ pub(crate) fn sign_with_keys(
+ self,
+ keys: &RadrootsNostrKeys,
+ ) -> Result<RadrootsNostrEvent, Error> {
let event = self.unsigned_event.clone().sign_with_keys(keys)?;
self.complete(event)
}
@@ -215,6 +249,7 @@ impl GenericBuilder {
unsigned_event,
expected_event_id,
expected_public_key: public_key,
+ authored_plan: None,
})
}
@@ -449,4 +484,77 @@ mod tests {
Err(Error::ExternalSigningEventInvalid(_))
));
}
+
+ #[test]
+ fn authored_plan_external_signing_preserves_and_checks_every_exact_field() {
+ use radroots_event::{GenericEventDraft, envelope::kind::KIND_GEOCHAT};
+ use radroots_event_codec::authoring::AuthoredEventPlan;
+
+ let keys = keys();
+ let author = keys.public_key().to_hex();
+ let plan = AuthoredEventPlan::from_generic(
+ GenericEventDraft::new(
+ "radroots.social.geochat.v1",
+ KIND_GEOCHAT,
+ 1_700_000_123,
+ vec![
+ vec!["g".to_owned(), "u4pru".to_owned()],
+ vec!["p".to_owned(), author.clone()],
+ ],
+ " exact content\nš ",
+ &author,
+ )
+ .expect("generic authored input"),
+ )
+ .expect("authored plan");
+ let request = ExternalSigningRequest::from_authored_plan(plan.clone())
+ .expect("plan-backed signing request");
+ assert_eq!(request.authored_plan(), Some(&plan));
+
+ let unsigned: nostr::UnsignedEvent =
+ serde_json::from_value(serde_json::to_value(&request).expect("request JSON"))
+ .expect("standard unsigned request");
+ assert_eq!(unsigned.id, Some(request.expected_event_id()));
+ assert_eq!(unsigned.pubkey, request.expected_public_key());
+ assert_eq!(unsigned.created_at.as_secs(), plan.created_at());
+ assert_eq!(u32::from(unsigned.kind.as_u16()), plan.body().kind());
+ assert_eq!(
+ unsigned
+ .tags
+ .iter()
+ .map(|tag| tag.as_slice().to_vec())
+ .collect::<Vec<_>>(),
+ plan.body().tags()
+ );
+ assert_eq!(unsigned.content, plan.body().content());
+
+ let valid = unsigned
+ .sign_with_keys(&keys)
+ .expect("external signer result");
+ request.complete(valid.clone()).expect("exact completion");
+
+ type PlanMutation = (&'static str, fn(&mut RadrootsNostrEvent));
+ let mutations: [PlanMutation; 4] = [
+ ("created_at", |event| {
+ event.created_at = RadrootsNostrTimestamp::from_secs(1_700_000_124);
+ }),
+ ("kind", |event| {
+ event.kind = RadrootsNostrKind::Custom(KIND_GEOCHAT as u16 + 1);
+ }),
+ ("tags", |event| {
+ event.tags = nostr::Tags::from_list(event.tags.iter().rev().cloned().collect());
+ }),
+ ("content", |event| event.content.push_str("changed")),
+ ];
+ for (field, mutate) in mutations {
+ let request = ExternalSigningRequest::from_authored_plan(plan.clone())
+ .expect("plan-backed signing request");
+ let mut tampered = valid.clone();
+ mutate(&mut tampered);
+ assert!(matches!(
+ request.complete(tampered),
+ Err(Error::ExternalSigningPlanMismatch { field: actual }) if actual == field
+ ));
+ }
+ }
}
diff --git a/crates/nostr/tests/generic_builder_boundary.rs b/crates/nostr/tests/generic_builder_boundary.rs
@@ -59,6 +59,47 @@ fn public_source_does_not_expose_the_upstream_event_builder() {
);
}
+#[test]
+fn every_strict_builder_is_plan_backed_and_has_no_unchecked_mapping_path() {
+ let crate_root = Path::new(env!("CARGO_MANIFEST_DIR"));
+ for (module, expected_body_conversions) in [
+ ("metadata.rs", 1),
+ ("post.rs", 3),
+ ("reply.rs", 1),
+ ("deletion.rs", 1),
+ ("comment.rs", 1),
+ ("food_availability.rs", 1),
+ ] {
+ let source = fs::read_to_string(crate_root.join("src/events").join(module))
+ .unwrap_or_else(|error| panic!("read {module}: {error}"));
+ assert!(
+ source.contains("SealedBuilderCore"),
+ "strict builder module {module} does not use the shared plan core"
+ );
+ assert_eq!(
+ source.matches("AuthoredEventBody::from_").count(),
+ expected_body_conversions,
+ "strict builder module {module} has an incomplete body-conversion inventory"
+ );
+ for forbidden in [
+ "build_event_unchecked",
+ "RadrootsNostrEventBuilderUnchecked",
+ "_to_wire_parts",
+ ] {
+ assert!(
+ !source.contains(forbidden),
+ "strict builder module {module} retains unchecked mapping `{forbidden}`"
+ );
+ }
+ for required in ["sign_with_keys", "into_external_signing_request"] {
+ assert!(
+ source.contains(required),
+ "strict builder module {module} is missing `{required}`"
+ );
+ }
+ }
+}
+
fn rust_source_files(root: &Path) -> Vec<PathBuf> {
let mut paths = Vec::new();
collect_rust_source_files(root, &mut paths);
diff --git a/crates/nostr/tests/profile_event_builder.rs b/crates/nostr/tests/profile_event_builder.rs
@@ -33,3 +33,29 @@ fn typed_profile_builder_preserves_the_strict_replacement_snapshot() {
);
assert!(event.verify().is_ok());
}
+
+#[test]
+fn typed_profile_builder_finalizes_the_same_plan_for_an_external_signer() {
+ let keys = RadrootsNostrKeys::new(
+ RadrootsNostrSecretKey::from_hex(test_fixtures::FIXTURE_ALICE_SECRET_KEY_HEX).unwrap(),
+ );
+ let created_at = RadrootsNostrTimestamp::from_secs(1_784_347_200);
+ let profile = AuthoredProfile::new("Alice").unwrap().with_bot(false);
+ let request = build_profile_event(&profile)
+ .unwrap()
+ .custom_created_at(created_at)
+ .into_external_signing_request(keys.public_key())
+ .expect("typed external request");
+ let plan = request.authored_plan().expect("typed request plan").clone();
+ assert_eq!(
+ plan.body().contract().contract_id().as_str(),
+ "radroots.profile.metadata.v1"
+ );
+ assert_eq!(plan.created_at(), created_at.as_secs());
+
+ let unsigned: nostr::UnsignedEvent =
+ serde_json::from_value(serde_json::to_value(&request).unwrap()).unwrap();
+ let event = unsigned.sign_with_keys(&keys).unwrap();
+ let completed = request.complete(event).expect("exact typed completion");
+ assert_eq!(completed.id.to_hex(), plan.expected_event_id().to_hex());
+}