lib

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

commit 48a6d37106a2bbcc4b8e9c5f172ea9729ef14852
parent f550b249b22eba95d2e576feb7ff0b4a704e8535
Author: triesap <tyson@radroots.org>
Date:   Sun, 19 Jul 2026 19:49:57 +0000

nostr: seal external generic event signing

- finalize generic builders only after typed-authoring policy validation
- serialize opaque requests with the standard unsigned-event wire shape
- verify exact author, event id, content commitment, and signature on completion
- cover feature-minimal, signer, clippy, and contract validation lanes

Diffstat:
MCHANGELOG.md | 4++++
Mcrates/nostr/README | 8++++++++
Mcrates/nostr/src/error.rs | 15+++++++++++++++
Mcrates/nostr/src/lib.rs | 7++++---
Mcrates/nostr/src/types.rs | 211++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/nostr/tests/generic_builder_boundary.rs | 8++++++++
6 files changed, 249 insertions(+), 4 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -91,6 +91,10 @@ publish policy both pass for the same source revision. ### Added +- Generic protocol builders can now finalize into an opaque checked external + signing request. The request preserves the standard unsigned-event JSON wire + shape while preventing raw mutation or unchecked reconstruction, and it + accepts only an exact author/id match with a valid NIP-01 signature. - Kind `30402` now has one allocation-free raw marker-name partition that distinguishes focused FoodAvailability, richer Operational Listing, marker-free generic NIP-99, and mixed ambiguous inputs before profile diff --git a/crates/nostr/README b/crates/nostr/README @@ -110,6 +110,14 @@ explicit low-level Nostr interoperability boundaries; their outputs carry no Radroots typed product-authoring claim and are not product authoring entry points. +Generic protocol events that require an external custody provider finalize +through `RadrootsNostrExternalSigningRequest`. The opaque request is available +without the relay-client feature and serializes as a standard unsigned Nostr +event only after generic typed-authoring reservations pass. It accepts a +returned event only when the author and canonical event id match the request +and the complete NIP-01 event verifies. It exposes no raw mutable builder, +unsigned-event conversion, or unchecked deserialization path. + ## Portable relay-client lifecycle With the `client` feature, callers can subscribe and publish to selected relay diff --git a/crates/nostr/src/error.rs b/crates/nostr/src/error.rs @@ -20,6 +20,21 @@ pub enum RadrootsNostrError { #[error("Nostr event kind {kind} requires typed authoring")] TypedAuthoringRequired { kind: u16 }, + #[error("External signing author mismatch: expected {expected}, got {actual}")] + ExternalSigningAuthorMismatch { + expected: nostr::PublicKey, + actual: nostr::PublicKey, + }, + + #[error("External signing event ID mismatch: expected {expected}, got {actual}")] + ExternalSigningEventIdMismatch { + expected: nostr::EventId, + actual: nostr::EventId, + }, + + #[error("External signing event is invalid: {0}")] + ExternalSigningEventInvalid(#[source] nostr::event::Error), + #[error("Event error: {0}")] EventError(#[from] nostr::event::Error), diff --git a/crates/nostr/src/lib.rs b/crates/nostr/src/lib.rs @@ -127,9 +127,10 @@ pub mod prelude { }; pub use crate::tags::*; pub use crate::types::{ - RadrootsNostrCoordinate, RadrootsNostrEvent, RadrootsNostrEventId, RadrootsNostrFilter, - RadrootsNostrFromBech32, RadrootsNostrGenericEventBuilder, RadrootsNostrKeys, - RadrootsNostrKind, RadrootsNostrMetadata, RadrootsNostrPublicKey, RadrootsNostrRelayUrl, + RadrootsNostrCoordinate, RadrootsNostrEvent, RadrootsNostrEventId, + RadrootsNostrExternalSigningRequest, RadrootsNostrFilter, RadrootsNostrFromBech32, + RadrootsNostrGenericEventBuilder, RadrootsNostrKeys, RadrootsNostrKind, + RadrootsNostrMetadata, RadrootsNostrPublicKey, RadrootsNostrRelayUrl, RadrootsNostrSecp256k1SecretKey, RadrootsNostrSecretKey, RadrootsNostrSubscriptionId, RadrootsNostrTag, RadrootsNostrTagKind, RadrootsNostrTagStandard, RadrootsNostrTimestamp, RadrootsNostrToBech32, RadrootsNostrUrl, diff --git a/crates/nostr/src/types.rs b/crates/nostr/src/types.rs @@ -23,6 +23,77 @@ pub type RadrootsNostrTagStandard = nostr::TagStandard; pub type RadrootsNostrTimestamp = nostr::Timestamp; pub type RadrootsNostrUrl = nostr::Url; +/// A checked generic 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. +/// +/// ```compile_fail +/// use radroots_nostr::prelude::RadrootsNostrExternalSigningRequest; +/// +/// let _: RadrootsNostrExternalSigningRequest = +/// serde_json::from_str("{}").expect("request"); +/// ``` +#[must_use = "external signing requests must be completed by a signer"] +pub struct RadrootsNostrExternalSigningRequest { + unsigned_event: nostr::UnsignedEvent, + expected_event_id: RadrootsNostrEventId, + expected_public_key: RadrootsNostrPublicKey, +} + +impl RadrootsNostrExternalSigningRequest { + pub fn expected_event_id(&self) -> RadrootsNostrEventId { + self.expected_event_id + } + + pub fn expected_public_key(&self) -> RadrootsNostrPublicKey { + self.expected_public_key + } + + /// Accepts an external signing result only when it is the exact requested + /// event and its NIP-01 identifier and signature are valid. + pub fn complete( + self, + event: RadrootsNostrEvent, + ) -> Result<RadrootsNostrEvent, RadrootsNostrError> { + if event.pubkey != self.expected_public_key { + return Err(RadrootsNostrError::ExternalSigningAuthorMismatch { + expected: self.expected_public_key, + actual: event.pubkey, + }); + } + if event.id != self.expected_event_id { + return Err(RadrootsNostrError::ExternalSigningEventIdMismatch { + expected: self.expected_event_id, + actual: event.id, + }); + } + event + .verify() + .map_err(RadrootsNostrError::ExternalSigningEventInvalid)?; + Ok(event) + } + + fn sign_with_keys( + self, + keys: &RadrootsNostrKeys, + ) -> Result<RadrootsNostrEvent, RadrootsNostrError> { + let event = self.unsigned_event.clone().sign_with_keys(keys)?; + self.complete(event) + } +} + +impl serde::Serialize for RadrootsNostrExternalSigningRequest { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + self.unsigned_event.serialize(serializer) + } +} + /// An opaque builder for generic Nostr events. /// /// Kind 0 profile events, all kind 1 events, all kind 5 deletion requests, kind @@ -123,8 +194,24 @@ impl RadrootsNostrGenericEventBuilder { self, keys: &RadrootsNostrKeys, ) -> Result<RadrootsNostrEvent, RadrootsNostrError> { + self.into_external_signing_request(keys.public_key())? + .sign_with_keys(keys) + } + + /// Finalizes a generic event for an external signer after enforcing typed + /// authoring reservations. + pub fn into_external_signing_request( + self, + public_key: RadrootsNostrPublicKey, + ) -> Result<RadrootsNostrExternalSigningRequest, RadrootsNostrError> { self.validate_generic_authoring_policy()?; - Ok(self.inner.sign_with_keys(keys)?) + let mut unsigned_event = self.inner.build(public_key); + let expected_event_id = unsigned_event.id(); + Ok(RadrootsNostrExternalSigningRequest { + unsigned_event, + expected_event_id, + expected_public_key: public_key, + }) } pub(crate) fn from_unchecked(inner: RadrootsNostrEventBuilderUnchecked) -> Self { @@ -281,4 +368,126 @@ mod tests { assert_eq!(event.kind.as_u16(), 30_001); } + + #[test] + fn external_signing_request_serializes_as_canonical_unsigned_event() { + let keys = keys(); + let request = + RadrootsNostrGenericEventBuilder::new(RadrootsNostrKind::Custom(24_133), "protocol") + .custom_created_at(RadrootsNostrTimestamp::from_secs(1_234)) + .into_external_signing_request(keys.public_key()) + .expect("checked request"); + let expected_event_id = request.expected_event_id(); + + let encoded = serde_json::to_vec(&request).expect("serialize request"); + let unsigned_event: nostr::UnsignedEvent = + serde_json::from_slice(&encoded).expect("standard unsigned event"); + + assert_eq!(unsigned_event.id, Some(expected_event_id)); + assert_eq!(unsigned_event.pubkey, keys.public_key()); + assert_eq!(unsigned_event.created_at.as_secs(), 1_234); + assert_eq!(unsigned_event.kind.as_u16(), 24_133); + assert_eq!(unsigned_event.content, "protocol"); + unsigned_event.verify_id().expect("canonical event id"); + } + + #[test] + fn external_signing_request_rejects_reserved_authoring_before_finalization() { + let error = match RadrootsNostrGenericEventBuilder::text_note("reserved") + .into_external_signing_request(keys().public_key()) + { + Ok(_) => panic!("kind 1 remains typed-only"), + Err(error) => error, + }; + + assert!(matches!( + error, + RadrootsNostrError::TypedAuthoringRequired { kind } + if kind == RadrootsNostrKind::TextNote.as_u16() + )); + } + + #[test] + fn external_signing_request_accepts_only_the_exact_valid_event() { + let keys = keys(); + let request = + RadrootsNostrGenericEventBuilder::new(RadrootsNostrKind::Custom(24_133), "protocol") + .custom_created_at(RadrootsNostrTimestamp::from_secs(1_234)) + .into_external_signing_request(keys.public_key()) + .expect("checked request"); + let unsigned_event: nostr::UnsignedEvent = + serde_json::from_value(serde_json::to_value(&request).expect("request value")) + .expect("unsigned event"); + let valid_event = unsigned_event + .sign_with_keys(&keys) + .expect("valid signing result"); + + let wrong_author = + RadrootsNostrGenericEventBuilder::new(RadrootsNostrKind::Custom(24_133), "protocol") + .custom_created_at(RadrootsNostrTimestamp::from_secs(1_234)) + .sign_with_keys(&RadrootsNostrKeys::generate()) + .expect("other author event"); + assert!(matches!( + request.complete(wrong_author), + Err(RadrootsNostrError::ExternalSigningAuthorMismatch { .. }) + )); + + let request = + RadrootsNostrGenericEventBuilder::new(RadrootsNostrKind::Custom(24_133), "protocol") + .custom_created_at(RadrootsNostrTimestamp::from_secs(1_234)) + .into_external_signing_request(keys.public_key()) + .expect("checked request"); + let wrong_event_id = + RadrootsNostrGenericEventBuilder::new(RadrootsNostrKind::Custom(24_133), "different") + .sign_with_keys(&keys) + .expect("different event"); + assert!(matches!( + request.complete(wrong_event_id), + Err(RadrootsNostrError::ExternalSigningEventIdMismatch { .. }) + )); + + for mutate in [ + |event: &mut RadrootsNostrEvent| event.content.push_str(" tampered"), + |event: &mut RadrootsNostrEvent| { + event.kind = RadrootsNostrKind::Custom(24_134); + }, + |event: &mut RadrootsNostrEvent| { + event.tags = nostr::Tags::from_list(vec![RadrootsNostrTag::custom( + RadrootsNostrTagKind::custom("x"), + ["tampered"], + )]); + }, + ] { + let request = RadrootsNostrGenericEventBuilder::new( + RadrootsNostrKind::Custom(24_133), + "protocol", + ) + .custom_created_at(RadrootsNostrTimestamp::from_secs(1_234)) + .into_external_signing_request(keys.public_key()) + .expect("checked request"); + let mut tampered = valid_event.clone(); + mutate(&mut tampered); + assert!(matches!( + request.complete(tampered), + Err(RadrootsNostrError::ExternalSigningEventInvalid(_)) + )); + } + + let other_signature = + RadrootsNostrGenericEventBuilder::new(RadrootsNostrKind::Custom(24_133), "different") + .sign_with_keys(&keys) + .expect("other event") + .sig; + let request = + RadrootsNostrGenericEventBuilder::new(RadrootsNostrKind::Custom(24_133), "protocol") + .custom_created_at(RadrootsNostrTimestamp::from_secs(1_234)) + .into_external_signing_request(keys.public_key()) + .expect("checked request"); + let mut invalid_signature = valid_event; + invalid_signature.sig = other_signature; + assert!(matches!( + request.complete(invalid_signature), + Err(RadrootsNostrError::ExternalSigningEventInvalid(_)) + )); + } } diff --git a/crates/nostr/tests/generic_builder_boundary.rs b/crates/nostr/tests/generic_builder_boundary.rs @@ -34,6 +34,14 @@ fn public_source_does_not_expose_the_upstream_event_builder() { "impl AsRef<nostr::EventBuilder> for RadrootsNostrGenericEventBuilder", "impl From<RadrootsNostrGenericEventBuilder> for nostr::EventBuilder", "impl Into<nostr::EventBuilder> for RadrootsNostrGenericEventBuilder", + "impl Deref for RadrootsNostrExternalSigningRequest", + "impl AsRef<nostr::UnsignedEvent> for RadrootsNostrExternalSigningRequest", + "impl From<RadrootsNostrExternalSigningRequest> for nostr::UnsignedEvent", + "impl Into<nostr::UnsignedEvent> for RadrootsNostrExternalSigningRequest", + "impl Deserialize for RadrootsNostrExternalSigningRequest", + "fn into_unsigned_event", + "fn as_unsigned_event", + "fn unsigned_event_mut", ] { if normalized.contains(forbidden) { findings.push(format!(