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:
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!(