lib

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

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:
Mcrates/event_codec/src/authoring/typed.rs | 165++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mcrates/nostr/src/error.rs | 7+++++++
Mcrates/nostr/src/events/comment.rs | 27++++++++++++++++-----------
Mcrates/nostr/src/events/deletion.rs | 29++++++++++++++++-------------
Mcrates/nostr/src/events/food_availability.rs | 26+++++++++++++++++++-------
Mcrates/nostr/src/events/metadata.rs | 28+++++++++++++++++++++-------
Mcrates/nostr/src/events/mod.rs | 2++
Mcrates/nostr/src/events/post.rs | 39++++++++++++++++++++++++---------------
Mcrates/nostr/src/events/reply.rs | 28++++++++++++++++------------
Acrates/nostr/src/events/sealed.rs | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/nostr/src/lib.rs | 2++
Acrates/nostr/src/plan_signing.rs | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/nostr/src/types.rs | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/nostr/tests/generic_builder_boundary.rs | 41+++++++++++++++++++++++++++++++++++++++++
Mcrates/nostr/tests/profile_event_builder.rs | 26++++++++++++++++++++++++++
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()); +}