app

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

commit 9e68fb26ba41bb74b1bcb6f6f1aebc1ea6361ee3
parent 249e12d4c6eaaa71e6fd46b0dc330af684af6c40
Author: triesap <tyson@radroots.org>
Date:   Sun,  9 Aug 2026 17:48:11 +0000

nostr: own the Studio Nostr adapter

- copy the exact locked Studio Nostr adapter into HarvestCircle core
- register the adapter as a local workspace member and dependency
- retain canonical identity and transport packages at one immutable revision
- refresh the lockfile after the source-boundary change

Diffstat:
Mcore/Cargo.lock | 37++++++++++++++++++++++++++++++++++---
Mcore/Cargo.toml | 5++++-
Acore/crates/studio_nostr/Cargo.toml | 29+++++++++++++++++++++++++++++
Acore/crates/studio_nostr/src/client.rs | 339+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/studio_nostr/src/keys.rs | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/studio_nostr/src/lib.rs | 9+++++++++
Acore/crates/studio_nostr/src/profile.rs | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 722 insertions(+), 4 deletions(-)

diff --git a/core/Cargo.lock b/core/Cargo.lock @@ -1524,6 +1524,22 @@ dependencies = [ ] [[package]] +name = "nostr-relay-builder" +version = "0.44.0" +source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219" +dependencies = [ + "async-utility", + "async-wsocket", + "atomic-destructor", + "hex", + "negentropy", + "nostr 0.44.1", + "nostr-database 0.44.0 (git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219)", + "tokio", + "tracing", +] + +[[package]] name = "nostr-relay-pool" version = "0.44.0" source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219" @@ -1991,7 +2007,7 @@ dependencies = [ "quote", "radroots_studio_application 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)", "radroots_studio_domain 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)", - "radroots_studio_nostr", + "radroots_studio_nostr 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)", "radroots_studio_runtime", "radroots_studio_storage 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)", "sha2", @@ -2003,6 +2019,21 @@ dependencies = [ [[package]] name = "radroots_studio_nostr" version = "0.1.0-alpha" +dependencies = [ + "nostr 0.44.1", + "nostr-relay-builder", + "nostr-sdk 0.44.0", + "radroots_identity", + "radroots_studio_application 0.1.0-alpha", + "radroots_studio_domain 0.1.0-alpha", + "radroots_transport", + "radroots_transport_nostr", + "tokio", +] + +[[package]] +name = "radroots_studio_nostr" +version = "0.1.0-alpha" source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3" dependencies = [ "nostr 0.44.1", @@ -2029,7 +2060,7 @@ source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c dependencies = [ "radroots_studio_application 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)", "radroots_studio_domain 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)", - "radroots_studio_nostr", + "radroots_studio_nostr 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)", "radroots_studio_storage 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)", "tokio", "uuid", @@ -2042,7 +2073,7 @@ dependencies = [ "radroots_studio_application 0.1.0-alpha", "radroots_studio_domain 0.1.0-alpha", "radroots_studio_ffi", - "radroots_studio_nostr", + "radroots_studio_nostr 0.1.0-alpha", "radroots_studio_preferences", "radroots_studio_runtime", "radroots_studio_storage 0.1.0-alpha", diff --git a/core/Cargo.toml b/core/Cargo.toml @@ -3,6 +3,7 @@ members = [ "crates/source_lock", "crates/studio_application", "crates/studio_domain", + "crates/studio_nostr", "crates/studio_preferences", "crates/studio_storage", ] @@ -29,11 +30,13 @@ pedantic = "deny" radroots_studio_application = { path = "crates/studio_application", version = "=0.1.0-alpha" } radroots_studio_domain = { path = "crates/studio_domain", version = "=0.1.0-alpha" } radroots_studio_ffi = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" } -radroots_studio_nostr = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" } +radroots_studio_nostr = { path = "crates/studio_nostr", version = "=0.1.0-alpha" } radroots_studio_preferences = { path = "crates/studio_preferences", version = "=0.1.0-alpha" } radroots_studio_runtime = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" } radroots_studio_storage = { path = "crates/studio_storage", version = "=0.1.0-alpha" } radroots_identity = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha", default-features = false } +radroots_transport = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha", default-features = false } +radroots_transport_nostr = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha", default-features = false } getrandom = { version = "0.2", default-features = false } hmac = { version = "0.12", default-features = false } rustix = { version = "1", features = ["fs", "process", "std"] } diff --git a/core/crates/studio_nostr/Cargo.toml b/core/crates/studio_nostr/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "radroots_studio_nostr" +description = "Private Nostr adapter for Radroots Studio" +version = "0.1.0-alpha" +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +license = "GPL-3.0-only" +repository.workspace = true +homepage.workspace = true +publish = false +include = ["src/**", "Cargo.toml"] + +[dependencies] +nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr" } +nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-sdk" } +radroots_studio_application.workspace = true +radroots_studio_domain.workspace = true +radroots_identity.workspace = true +radroots_transport.workspace = true +radroots_transport_nostr.workspace = true +tokio = { version = "=1.47.1", features = ["sync", "time"] } + +[dev-dependencies] +nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-relay-builder" } +tokio = { version = "=1.47.1", features = ["macros", "rt-multi-thread", "sync", "time"] } + +[lints] +workspace = true diff --git a/core/crates/studio_nostr/src/client.rs b/core/crates/studio_nostr/src/client.rs @@ -0,0 +1,339 @@ +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use radroots_studio_domain::{ + PublicKey, RelayDestinationPolicy, RelayUrl, SafeError, SafeErrorCode, SafeMessage, + select_latest_kind0, +}; +use radroots_transport::{ + EventSource, FetchRequest, Target, TargetSet, + outcome::FetchTargetState, + source::{FetchBounds, FetchSelector}, +}; +use radroots_transport_nostr::{Config, NostrTransport, RelayUrlPolicy}; + +use radroots_studio_application::{ + BoxFuture, MAX_CONFIGURED_RELAYS, NostrClient, ProfileFetchResult, +}; + +pub struct SdkNostrClient { + timeout: Duration, +} + +const MAX_PROFILE_EVENTS_PER_RELAY: usize = 64; + +impl SdkNostrClient { + #[must_use] + pub const fn new(timeout: Duration) -> Self { + Self { timeout } + } +} + +impl NostrClient for SdkNostrClient { + fn fetch_profile<'a>( + &'a self, + public_key: PublicKey, + relays: &'a [RelayUrl], + deadline: Instant, + ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>> { + Box::pin(async move { + if relays.is_empty() { + return Err(invalid_relay_configuration()); + } + if relays.len() > MAX_CONFIGURED_RELAYS { + return Err(invalid_relay_configuration()); + } + + let author = public_key.canonical(); + let deadline = deadline.min(Instant::now() + self.timeout); + let mut candidates = Vec::new(); + let mut successful_relays = 0usize; + for policy in [ + RelayDestinationPolicy::Public, + RelayDestinationPolicy::Local, + RelayDestinationPolicy::PrivateNetwork, + ] { + let policy_relays = relays + .iter() + .filter(|relay| relay.policy() == policy) + .collect::<Vec<_>>(); + if policy_relays.is_empty() { + continue; + } + let config = Config::new( + canonical_policy(policy), + policy_relays.iter().map(|relay| relay.as_str()), + ) + .and_then(|config| { + let timeout_ms = + timeout_millis(deadline.saturating_duration_since(Instant::now())); + config.with_timeouts(timeout_ms, timeout_ms, timeout_ms) + }) + .map_err(|_| invalid_relay_configuration())?; + let targets = policy_relays + .iter() + .map(|relay| Target::nostr_relay(relay.as_str())) + .collect::<Result<Vec<_>, _>>() + .map_err(|_| invalid_relay_configuration())?; + let request = FetchRequest::new( + format!("studio-profile-{policy:?}"), + TargetSet::new(targets).map_err(|_| invalid_relay_configuration())?, + FetchBounds::new( + MAX_PROFILE_EVENTS_PER_RELAY as u16, + unix_deadline(deadline.saturating_duration_since(Instant::now()))?, + ) + .map_err(|_| invalid_relay_configuration())?, + ) + .map_err(|_| invalid_relay_configuration())? + .with_selector( + FetchSelector::all() + .with_kinds(vec![0]) + .and_then(|selector| selector.with_authors(vec![author])) + .map_err(|_| invalid_relay_configuration())?, + ); + let page = tokio::time::timeout_at( + deadline.into(), + NostrTransport::new(config).fetch(request), + ) + .await + .map_err(|_| relay_connection_failed())? + .map_err(|_| relay_connection_failed())?; + successful_relays += page + .target_outcomes() + .iter() + .filter(|outcome| { + matches!( + outcome.state(), + FetchTargetState::Complete | FetchTargetState::Partial + ) + }) + .count(); + for observed in page.events() { + candidates.push(crate::parse_verified_kind0( + observed.event().raw_json(), + public_key, + )?); + } + } + if successful_relays == 0 { + return Err(relay_connection_failed()); + } + let candidate = select_latest_kind0(candidates); + if successful_relays == relays.len() { + Ok(ProfileFetchResult::complete(candidate)) + } else { + Ok(ProfileFetchResult::partial(candidate)) + } + }) + } +} + +fn unix_deadline(timeout: Duration) -> Result<u64, SafeError> { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| relay_connection_failed())?; + let deadline = now + .checked_add(timeout) + .ok_or_else(relay_connection_failed)?; + u64::try_from(deadline.as_millis()) + .ok() + .filter(|deadline| *deadline > 0) + .ok_or_else(relay_connection_failed) +} + +fn timeout_millis(timeout: Duration) -> u64 { + u64::try_from(timeout.as_millis()) + .unwrap_or(u64::MAX) + .clamp(1, 120_000) +} + +const fn canonical_policy(policy: RelayDestinationPolicy) -> RelayUrlPolicy { + match policy { + RelayDestinationPolicy::Public => RelayUrlPolicy::Public, + RelayDestinationPolicy::Local => RelayUrlPolicy::Local, + RelayDestinationPolicy::PrivateNetwork => RelayUrlPolicy::PrivateNetwork, + } +} + +const fn invalid_relay_configuration() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidRelayConfiguration, + SafeMessage::new("No Nostr relay is configured."), + ) +} + +const fn relay_connection_failed() -> SafeError { + SafeError::new( + SafeErrorCode::RelayConnectionFailed, + SafeMessage::new("The Nostr relays could not be reached."), + ) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use nostr::{EventBuilder, Keys, Metadata}; + use nostr_relay_builder::MockRelay; + use nostr_sdk::Client; + use radroots_studio_domain::{PublicKey, RelayDestinationPolicy, RelayUrl, SafeErrorCode}; + + use radroots_studio_application::NostrClient; + + use crate::SdkNostrClient; + + #[tokio::test] + async fn sdk_client_fetches_verified_profile_from_ephemeral_local_relay() { + let relay = MockRelay::run().await.expect("local relay"); + let relay_url = relay.url().await; + let keys = Keys::generate(); + let publisher = Client::new(keys.clone()); + publisher + .add_relay(relay_url.clone()) + .await + .expect("add relay"); + publisher.connect().await; + publisher.wait_for_connection(Duration::from_secs(2)).await; + publisher + .send_event_builder(EventBuilder::metadata( + &Metadata::new().name("Farmer").display_name("Farm Account"), + )) + .await + .expect("publish metadata"); + + let adapter = SdkNostrClient::new(Duration::from_secs(2)); + let domain_relay = RelayUrl::parse(relay_url.as_str(), RelayDestinationPolicy::Local) + .expect("domain relay URL"); + let public_key = + PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key"); + let fetched = adapter + .fetch_profile( + public_key, + &[domain_relay], + std::time::Instant::now() + Duration::from_secs(2), + ) + .await + .expect("fetch profile"); + let (profile, completeness) = fetched.into_parts(); + let profile = profile.expect("published profile"); + + assert_eq!(profile.author(), public_key); + assert_eq!(profile.metadata().preferred_name(), Some("Farm Account")); + assert_eq!( + completeness, + radroots_studio_application::RelayFetchCompleteness::Complete + ); + publisher.shutdown().await; + relay.shutdown(); + } + + #[tokio::test] + async fn sdk_client_rejects_empty_configuration_without_network_access() { + let error = SdkNostrClient::new(Duration::from_millis(10)) + .fetch_profile( + PublicKey::from_bytes([7; 32]).expect("valid public key"), + &[], + std::time::Instant::now() + Duration::from_millis(10), + ) + .await + .expect_err("empty relay list"); + + assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration); + + let relay = RelayUrl::parse("wss://relay.example.test", RelayDestinationPolicy::Public) + .expect("relay URL"); + let too_many = vec![relay; radroots_studio_application::MAX_CONFIGURED_RELAYS + 1]; + let error = SdkNostrClient::new(Duration::from_millis(10)) + .fetch_profile( + PublicKey::from_bytes([7; 32]).expect("valid public key"), + &too_many, + std::time::Instant::now() + Duration::from_millis(10), + ) + .await + .expect_err("oversized relay list"); + assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration); + } + + #[tokio::test] + async fn sdk_client_fails_when_no_configured_relay_completes() { + let relay = RelayUrl::parse("ws://127.0.0.1:1", RelayDestinationPolicy::Local) + .expect("unavailable relay"); + let error = SdkNostrClient::new(Duration::from_millis(25)) + .fetch_profile( + PublicKey::from_bytes([7; 32]).expect("valid public key"), + &[relay], + std::time::Instant::now() + Duration::from_millis(50), + ) + .await + .expect_err("all relays unavailable"); + assert_eq!(error.code(), SafeErrorCode::RelayConnectionFailed); + } + + #[tokio::test] + async fn sdk_client_reports_partial_when_one_configured_relay_is_unavailable() { + let relay = MockRelay::run().await.expect("local relay"); + let relay_url = relay.url().await; + let keys = Keys::generate(); + let publisher = Client::new(keys.clone()); + publisher + .add_relay(relay_url.clone()) + .await + .expect("add relay"); + publisher.connect().await; + publisher + .send_event_builder(EventBuilder::metadata(&Metadata::new().name("Partial"))) + .await + .expect("publish metadata"); + + let configured = [ + RelayUrl::parse(relay_url.as_str(), RelayDestinationPolicy::Local).expect("live relay"), + RelayUrl::parse("ws://127.0.0.1:1", RelayDestinationPolicy::Local) + .expect("unavailable relay"), + ]; + let fetched = SdkNostrClient::new(Duration::from_millis(250)) + .fetch_profile( + PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key"), + &configured, + std::time::Instant::now() + Duration::from_secs(1), + ) + .await + .expect("partial fetch"); + let (candidate, completeness) = fetched.into_parts(); + assert!(candidate.is_some()); + assert_eq!( + completeness, + radroots_studio_application::RelayFetchCompleteness::Partial + ); + publisher.shutdown().await; + relay.shutdown(); + } + + #[test] + fn studio_policy_maps_exactly_to_the_canonical_transport_policy() { + assert_eq!( + super::canonical_policy(RelayDestinationPolicy::Public), + radroots_transport_nostr::RelayUrlPolicy::Public + ); + assert_eq!( + super::canonical_policy(RelayDestinationPolicy::Local), + radroots_transport_nostr::RelayUrlPolicy::Local + ); + assert_eq!( + super::canonical_policy(RelayDestinationPolicy::PrivateNetwork), + radroots_transport_nostr::RelayUrlPolicy::PrivateNetwork + ); + assert_eq!(super::timeout_millis(Duration::ZERO), 1); + assert_eq!( + super::timeout_millis(Duration::from_secs(1_000_000)), + 120_000 + ); + assert!(super::unix_deadline(Duration::from_secs(1)).is_ok()); + assert_eq!( + super::invalid_relay_configuration().code(), + SafeErrorCode::InvalidRelayConfiguration + ); + assert_eq!( + super::relay_connection_failed().code(), + SafeErrorCode::RelayConnectionFailed + ); + } +} diff --git a/core/crates/studio_nostr/src/keys.rs b/core/crates/studio_nostr/src/keys.rs @@ -0,0 +1,125 @@ +use nostr::{Keys, ToBech32}; +use radroots_studio_application::{GeneratedKeyMaterial, ImportedKeyMaterial, KeyMaterialProvider}; +use radroots_studio_domain::{ + Npub, Nsec, PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, +}; + +#[derive(Clone, Copy, Debug, Default)] +pub struct NostrKeyMaterialProvider; + +/// Generates one cryptographically random local Nostr keypair. +/// +/// # Errors +/// +/// Returns a safe key error if an upstream encoding cannot be represented by +/// the stricter Radroots domain boundary. +impl KeyMaterialProvider for NostrKeyMaterialProvider { + fn generate(&self) -> Result<GeneratedKeyMaterial, SafeError> { + let keys = Keys::generate(); + let (public_key, npub, secret, nsec) = encode_keys(&keys)?; + Ok(GeneratedKeyMaterial::new(public_key, npub, secret, nsec)) + } + + fn import(&self, input: SecretKeyInput) -> Result<ImportedKeyMaterial, SafeError> { + let keys = input + .with_exposed_secret(Keys::parse) + .map_err(|_| invalid_secret_key())?; + drop(input); + let public_key = PublicKey::from_bytes(keys.public_key().to_bytes())?; + let npub = keys + .public_key() + .to_bech32() + .map_err(|_| invalid_public_key()) + .and_then(Npub::from_encoded)?; + let secret = SecretKeyInput::parse(keys.secret_key().to_secret_hex())?; + Ok(ImportedKeyMaterial::new(public_key, npub, secret)) + } +} + +fn encode_keys(keys: &Keys) -> Result<(PublicKey, Npub, SecretKeyInput, Nsec), SafeError> { + let public_key = PublicKey::from_bytes(keys.public_key().to_bytes())?; + let npub = keys + .public_key() + .to_bech32() + .map_err(|_| invalid_public_key()) + .and_then(Npub::from_encoded)?; + let secret = SecretKeyInput::parse(keys.secret_key().to_secret_hex())?; + let nsec = keys + .secret_key() + .to_bech32() + .map_err(|_| invalid_secret_key()) + .and_then(Nsec::from_encoded)?; + Ok((public_key, npub, secret, nsec)) +} + +const fn invalid_secret_key() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidSecretKey, + SafeMessage::new("The Nostr secret key is invalid."), + ) +} + +const fn invalid_public_key() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidPublicKey, + SafeMessage::new("The Nostr public key is invalid."), + ) +} + +#[cfg(test)] +mod tests { + use radroots_studio_domain::{SafeErrorCode, SecretKeyInput}; + + use radroots_studio_application::KeyMaterialProvider; + + use super::{NostrKeyMaterialProvider, invalid_public_key, invalid_secret_key}; + + const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; + const NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"; + const NSEC_PUBLIC_HEX: &str = + "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e"; + const HEX_PUBLIC_HEX: &str = "0cfda0afa91cc2fbbd6050c285802fe95c7a1755e0f68323999e13760501dc40"; + + #[test] + fn keys_generate_valid_redacted_material() { + let generated = NostrKeyMaterialProvider.generate().expect("generated"); + let (public_key, npub, secret, nsec) = generated.into_parts(); + assert_eq!(public_key.to_hex().len(), 64); + assert!(npub.as_str().starts_with("npub1")); + assert_eq!(secret.with_exposed_secret(str::len), 64); + assert_eq!(nsec.with_exposed_secret(str::len), 63); + assert_eq!(secret.with_exposed_secret(str::len), 64); + assert_eq!(nsec.with_exposed_secret(str::len), 63); + } + + #[test] + fn keys_import_known_nsec_and_hex_vectors() { + let from_nsec = NostrKeyMaterialProvider + .import(SecretKeyInput::parse(NSEC.to_owned()).expect("nsec")) + .expect("import nsec"); + let from_hex = NostrKeyMaterialProvider + .import(SecretKeyInput::parse(SECRET_HEX.to_owned()).expect("hex")) + .expect("import hex"); + let (nsec_public, nsec_npub, _) = from_nsec.into_parts(); + let (hex_public, hex_npub, _) = from_hex.into_parts(); + assert_eq!(nsec_public.to_hex(), NSEC_PUBLIC_HEX); + assert_eq!(hex_public.to_hex(), HEX_PUBLIC_HEX); + assert!(nsec_npub.as_str().starts_with("npub1")); + assert!(hex_npub.as_str().starts_with("npub1")); + } + + #[test] + fn keys_reject_structurally_plausible_nsec_with_invalid_checksum() { + let input = SecretKeyInput::parse( + "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq".to_owned(), + ) + .expect("domain shape"); + let error = NostrKeyMaterialProvider + .import(input) + .err() + .expect("invalid checksum"); + assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey); + assert_eq!(invalid_secret_key().code(), SafeErrorCode::InvalidSecretKey); + assert_eq!(invalid_public_key().code(), SafeErrorCode::InvalidPublicKey); + } +} diff --git a/core/crates/studio_nostr/src/lib.rs b/core/crates/studio_nostr/src/lib.rs @@ -0,0 +1,9 @@ +#![doc = "Radroots Studio Nostr protocol adapters."] + +pub mod client; +pub mod keys; +pub mod profile; + +pub use client::SdkNostrClient; +pub use keys::NostrKeyMaterialProvider; +pub use profile::parse_verified_kind0; diff --git a/core/crates/studio_nostr/src/profile.rs b/core/crates/studio_nostr/src/profile.rs @@ -0,0 +1,182 @@ +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()).expect("valid public key"); + + 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()).expect("valid public key"); + let wrong_author = PublicKey::from_bytes(Keys::generate().public_key().to_bytes()) + .expect("valid public key"); + 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()).expect("valid public key"); + + 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 + ); + assert_eq!( + parse_verified_kind0(&"x".repeat(64 * 1_024 + 1), author) + .expect_err("oversized event") + .code(), + SafeErrorCode::ProfileRefreshFailed + ); + assert_eq!( + super::invalid_event().code(), + SafeErrorCode::ProfileRefreshFailed + ); + assert_eq!( + super::invalid_metadata().code(), + SafeErrorCode::InvalidProfileMetadata + ); + } +}