app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 4baaf49245ee295e43d2e889684dfafc557f110f
parent af00441a500a28ef1cdd0e974e68f258a3f37303
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 19:18:08 +0000

core(nostr): verify kind-zero profile events

- bound serialized events and profile content before conversion
- verify event identifiers signatures authors and metadata kind
- map supported public profile fields into domain values
- reject tampering malformed metadata and mismatched authors

Diffstat:
Mcore/crates/nostr/src/lib.rs | 2++
Acore/crates/nostr/src/profile.rs | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/implementation/nostr-runtime-rcld.md | 2+-
3 files changed, 168 insertions(+), 1 deletion(-)

diff --git a/core/crates/nostr/src/lib.rs b/core/crates/nostr/src/lib.rs @@ -1,5 +1,7 @@ #![doc = "Radroots Studio Nostr protocol adapters."] pub mod keys; +pub mod profile; pub use keys::{GeneratedKeyMaterial, ImportedKeyMaterial, generate_local_keypair, import_secret}; +pub use profile::parse_verified_kind0; diff --git a/core/crates/nostr/src/profile.rs b/core/crates/nostr/src/profile.rs @@ -0,0 +1,165 @@ +use nostr::{Event, JsonUtil, Kind, Metadata}; +use radroots_studio_domain::{ + EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, SafeError, SafeErrorCode, + SafeMessage, UnixTimestamp, +}; + +const MAX_EVENT_JSON_BYTES: usize = 64 * 1_024; +const MAX_PROFILE_CONTENT_BYTES: usize = 16 * 1_024; + +/// Verifies and converts one serialized Nostr kind-0 event. +/// +/// # Errors +/// +/// Returns a safe profile-refresh error when the event is oversized, +/// malformed, invalidly signed, authored by another key, or not kind 0. +pub fn parse_verified_kind0( + event_json: &str, + expected_author: PublicKey, +) -> Result<Kind0ProfileCandidate, SafeError> { + if event_json.len() > MAX_EVENT_JSON_BYTES { + return Err(invalid_event()); + } + + let event = Event::from_json(event_json).map_err(|_| invalid_event())?; + event.verify().map_err(|_| invalid_event())?; + if event.kind != Kind::Metadata + || event.pubkey.to_bytes() != *expected_author.as_bytes() + || event.content.len() > MAX_PROFILE_CONTENT_BYTES + { + return Err(invalid_event()); + } + + let metadata = Metadata::from_json(&event.content).map_err(|_| invalid_metadata())?; + let profile = ProfileMetadata::new( + metadata.name, + metadata.display_name, + metadata.nip05, + metadata.about, + metadata.picture, + )?; + let created_at = i64::try_from(event.created_at.as_secs()) + .ok() + .and_then(UnixTimestamp::from_seconds) + .ok_or_else(invalid_event)?; + + Ok(Kind0ProfileCandidate::new( + EventId::from_bytes(event.id.to_bytes()), + expected_author, + created_at, + profile, + )) +} + +const fn invalid_event() -> SafeError { + SafeError::new( + SafeErrorCode::ProfileRefreshFailed, + SafeMessage::new("The Nostr profile event is invalid."), + ) +} + +const fn invalid_metadata() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidProfileMetadata, + SafeMessage::new("The Nostr profile metadata is invalid."), + ) +} + +#[cfg(test)] +mod tests { + use nostr::{EventBuilder, JsonUtil, Keys, Metadata, Url}; + use radroots_studio_domain::{PublicKey, SafeErrorCode}; + + use super::parse_verified_kind0; + + fn signed_profile() -> (Keys, String) { + let keys = Keys::generate(); + let event = EventBuilder::metadata( + &Metadata::new() + .name(" farmer ") + .display_name(" Farm Account ") + .nip05("farmer@example.test") + .about("Local grower") + .picture( + Url::parse("https://images.example.test/farmer.png") + .expect("valid picture URL"), + ), + ) + .sign_with_keys(&keys) + .expect("signed metadata event"); + (keys, event.as_json()) + } + + #[test] + fn profile_event_verifies_signature_author_kind_and_metadata() { + let (keys, json) = signed_profile(); + let expected_author = PublicKey::from_bytes(keys.public_key().to_bytes()); + + let candidate = parse_verified_kind0(&json, expected_author).expect("verified profile"); + + assert_eq!(candidate.author(), expected_author); + assert_eq!(candidate.metadata().name(), Some("farmer")); + assert_eq!(candidate.metadata().display_name(), Some("Farm Account")); + assert_eq!(candidate.metadata().nip05(), Some("farmer@example.test")); + assert_eq!(candidate.metadata().about(), Some("Local grower")); + assert_eq!( + candidate.metadata().picture(), + Some("https://images.example.test/farmer.png") + ); + } + + #[test] + fn profile_event_rejects_tampering_wrong_author_kind_and_oversize_content() { + let (keys, json) = signed_profile(); + let expected_author = PublicKey::from_bytes(keys.public_key().to_bytes()); + let wrong_author = PublicKey::from_bytes(Keys::generate().public_key().to_bytes()); + let tampered = json.replace("Local grower", "Remote grower"); + let note = EventBuilder::text_note("not metadata") + .sign_with_keys(&keys) + .expect("signed note") + .as_json(); + let oversized = EventBuilder::metadata(&Metadata::new().about("x".repeat(16 * 1_024 + 1))) + .sign_with_keys(&keys) + .expect("signed oversized profile") + .as_json(); + + for rejected in [ + parse_verified_kind0(&tampered, expected_author), + parse_verified_kind0(&json, wrong_author), + parse_verified_kind0(&note, expected_author), + parse_verified_kind0(&oversized, expected_author), + ] { + assert_eq!( + rejected.expect_err("invalid event").code(), + SafeErrorCode::ProfileRefreshFailed + ); + } + } + + #[test] + fn profile_event_rejects_malformed_and_bounded_invalid_metadata() { + let keys = Keys::generate(); + let malformed = EventBuilder::new(nostr::Kind::Metadata, "not json") + .sign_with_keys(&keys) + .expect("signed malformed metadata") + .as_json(); + let invalid = EventBuilder::metadata(&Metadata::new().name("x".repeat(129))) + .sign_with_keys(&keys) + .expect("signed invalid metadata") + .as_json(); + let author = PublicKey::from_bytes(keys.public_key().to_bytes()); + + assert_eq!( + parse_verified_kind0(&malformed, author) + .expect_err("malformed metadata") + .code(), + SafeErrorCode::InvalidProfileMetadata + ); + assert_eq!( + parse_verified_kind0(&invalid, author) + .expect_err("bounded metadata") + .code(), + SafeErrorCode::InvalidProfileMetadata + ); + } +} diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md @@ -514,7 +514,7 @@ handoff commit sequence. ### RCLD-08 - [x] 36. Add relay configuration source and environment parser. -- [ ] 37. Implement Nostr event verification and kind-0 parsing adapter. +- [x] 37. Implement Nostr event verification and kind-0 parsing adapter. - [ ] 38. Add Nostr client port implementation and local relay fixture. - [ ] 39. Implement cache-first active profile refresh orchestration. - [ ] 40. Expose manual refreshActiveProfile command.