commit b642cbf7f2d964db0cf4bcecd72eea85966cf02e
parent 917d826f04a5555399514875d8963aa17ff819a8
Author: triesap <tyson@radroots.org>
Date: Fri, 12 Jun 2026 23:09:24 -0700
nostr: sign frozen event drafts
- add fixed-created-at signing for frozen Radroots event drafts
- reject signer pubkey and event ID mismatches with structured errors
- return the shared signed event wrapper with raw event JSON
- cover exact timestamp, wrong-signer, and event-id mismatch cases
Diffstat:
3 files changed, 135 insertions(+), 0 deletions(-)
diff --git a/crates/nostr/src/draft_signing.rs b/crates/nostr/src/draft_signing.rs
@@ -0,0 +1,110 @@
+#![forbid(unsafe_code)]
+
+use crate::error::RadrootsNostrError;
+use crate::event_convert::radroots_event_from_nostr;
+use crate::events::radroots_nostr_build_event;
+use crate::types::{RadrootsNostrKeys, RadrootsNostrTimestamp};
+use nostr::JsonUtil;
+use radroots_events::draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent};
+
+pub fn radroots_nostr_sign_frozen_draft(
+ keys: &RadrootsNostrKeys,
+ draft: &RadrootsFrozenEventDraft,
+) -> Result<RadrootsSignedNostrEvent, RadrootsNostrError> {
+ let actual_pubkey = keys.public_key().to_hex();
+ if actual_pubkey != draft.expected_pubkey {
+ return Err(RadrootsNostrError::FrozenDraftPubkeyMismatch {
+ expected_pubkey: draft.expected_pubkey.clone(),
+ actual_pubkey,
+ });
+ }
+
+ let event = radroots_nostr_build_event(draft.kind, draft.content.clone(), draft.tags.clone())?
+ .custom_created_at(RadrootsNostrTimestamp::from_secs(u64::from(
+ draft.created_at,
+ )))
+ .sign_with_keys(keys)?;
+ let actual_event_id = event.id.to_hex();
+ if actual_event_id != draft.expected_event_id {
+ return Err(RadrootsNostrError::FrozenDraftEventIdMismatch {
+ expected_event_id: draft.expected_event_id.clone(),
+ actual_event_id,
+ });
+ }
+
+ let raw_json = event.as_json();
+ RadrootsSignedNostrEvent::from_event(radroots_event_from_nostr(&event), raw_json)
+ .map_err(Into::into)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::radroots_nostr_sign_frozen_draft;
+ use crate::error::RadrootsNostrError;
+ use crate::test_fixtures::{FIXTURE_ALICE, FIXTURE_BOB};
+ use crate::types::{RadrootsNostrKeys, RadrootsNostrSecretKey};
+ use nostr::JsonUtil;
+ use radroots_events::draft::RadrootsFrozenEventDraft;
+ use radroots_events::kinds::KIND_POST;
+
+ fn fixture_keys(secret_key_hex: &str) -> RadrootsNostrKeys {
+ let secret_key = RadrootsNostrSecretKey::from_hex(secret_key_hex).expect("secret key");
+ RadrootsNostrKeys::new(secret_key)
+ }
+
+ fn post_draft(expected_pubkey: &str) -> RadrootsFrozenEventDraft {
+ RadrootsFrozenEventDraft::new(
+ "radroots.social.post.v1",
+ KIND_POST,
+ 1_700_000_000,
+ vec![vec!["t".to_owned(), "soil".to_owned()]],
+ "hello",
+ expected_pubkey,
+ )
+ .expect("draft")
+ }
+
+ #[test]
+ fn sign_frozen_draft_uses_fixed_created_at_and_expected_id() {
+ let keys = fixture_keys(FIXTURE_ALICE.secret_key_hex);
+ let draft = post_draft(FIXTURE_ALICE.public_key_hex);
+ let signed = radroots_nostr_sign_frozen_draft(&keys, &draft).expect("signed event");
+
+ assert_eq!(signed.id, draft.expected_event_id);
+ assert_eq!(signed.pubkey, draft.expected_pubkey);
+ assert_eq!(signed.created_at, draft.created_at);
+ assert_eq!(signed.kind, draft.kind);
+ assert_eq!(signed.tags, draft.tags);
+ assert_eq!(signed.content, draft.content);
+
+ let raw_event = crate::types::RadrootsNostrEvent::from_json(signed.raw_json.as_str())
+ .expect("raw json");
+ assert_eq!(raw_event.id.to_hex(), signed.id);
+ assert_eq!(raw_event.created_at.as_secs(), u64::from(draft.created_at));
+ }
+
+ #[test]
+ fn sign_frozen_draft_rejects_wrong_signer() {
+ let keys = fixture_keys(FIXTURE_BOB.secret_key_hex);
+ let draft = post_draft(FIXTURE_ALICE.public_key_hex);
+ let error = radroots_nostr_sign_frozen_draft(&keys, &draft).expect_err("wrong signer");
+
+ assert!(matches!(
+ error,
+ RadrootsNostrError::FrozenDraftPubkeyMismatch { .. }
+ ));
+ }
+
+ #[test]
+ fn sign_frozen_draft_rejects_event_id_mismatch() {
+ let keys = fixture_keys(FIXTURE_ALICE.secret_key_hex);
+ let mut draft = post_draft(FIXTURE_ALICE.public_key_hex);
+ draft.expected_event_id = "f".repeat(64);
+ let error = radroots_nostr_sign_frozen_draft(&keys, &draft).expect_err("id mismatch");
+
+ assert!(matches!(
+ error,
+ RadrootsNostrError::FrozenDraftEventIdMismatch { .. }
+ ));
+ }
+}
diff --git a/crates/nostr/src/error.rs b/crates/nostr/src/error.rs
@@ -23,6 +23,26 @@ pub enum RadrootsNostrError {
#[error("Event builder failure: {0}")]
EventBuildError(#[from] nostr::event::builder::Error),
+ #[cfg(feature = "events")]
+ #[error("Draft error: {0}")]
+ DraftError(#[from] radroots_events::draft::RadrootsDraftError),
+
+ #[cfg(feature = "events")]
+ #[error(
+ "Frozen draft signer public key mismatch: expected {expected_pubkey}, got {actual_pubkey}"
+ )]
+ FrozenDraftPubkeyMismatch {
+ expected_pubkey: String,
+ actual_pubkey: String,
+ },
+
+ #[cfg(feature = "events")]
+ #[error("Frozen draft event ID mismatch: expected {expected_event_id}, got {actual_event_id}")]
+ FrozenDraftEventIdMismatch {
+ expected_event_id: String,
+ actual_event_id: String,
+ },
+
#[error("Key error: {0}")]
KeyError(#[from] nostr::key::Error),
diff --git a/crates/nostr/src/lib.rs b/crates/nostr/src/lib.rs
@@ -34,6 +34,8 @@ pub mod nip11;
pub mod event_adapters;
#[cfg(feature = "events")]
+pub mod draft_signing;
+#[cfg(feature = "events")]
pub mod event_convert;
#[cfg(all(feature = "client", feature = "codec"))]
@@ -124,6 +126,9 @@ pub mod prelude {
pub use crate::event_adapters::{to_post_event_metadata, to_profile_event_metadata};
#[cfg(feature = "events")]
+ pub use crate::draft_signing::radroots_nostr_sign_frozen_draft;
+
+ #[cfg(feature = "events")]
pub use crate::event_convert::{radroots_event_from_nostr, radroots_event_ptr_from_nostr};
#[cfg(feature = "codec")]