app

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

commit a4d7deebec3e2ce2c1daa455de6d79857839aed0
parent c709d03f0ede14aacd67b6856d146daebe5b035e
Author: triesap <tyson@radroots.org>
Date:   Sun,  9 Aug 2026 17:42:10 +0000

domain: own the Studio domain crate

- copy the exact locked Studio domain source into HarvestCircle core
- register the domain crate as a local workspace dependency
- retain identity as an immutable canonical Radroots Git dependency
- refresh the lockfile after the source-boundary change

Diffstat:
Mcore/Cargo.lock | 23+++++++++++++++++------
Mcore/Cargo.toml | 9+++++++--
Acore/crates/studio_domain/Cargo.toml | 22++++++++++++++++++++++
Acore/crates/studio_domain/src/account.rs | 430+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/studio_domain/src/error.rs | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/studio_domain/src/key.rs | 451+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/studio_domain/src/lib.rs | 21+++++++++++++++++++++
Acore/crates/studio_domain/src/profile.rs | 298+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/studio_domain/src/relay.rs | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/studio_domain/src/time.rs | 36++++++++++++++++++++++++++++++++++++
10 files changed, 1593 insertions(+), 8 deletions(-)

diff --git a/core/Cargo.lock b/core/Cargo.lock @@ -1945,7 +1945,7 @@ name = "radroots_studio_application" version = "0.1.0-alpha" source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3" dependencies = [ - "radroots_studio_domain", + "radroots_studio_domain 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)", "secrecy", "tokio", ] @@ -1953,6 +1953,17 @@ dependencies = [ [[package]] name = "radroots_studio_domain" version = "0.1.0-alpha" +dependencies = [ + "bech32", + "radroots_identity", + "secrecy", + "url", + "zeroize", +] + +[[package]] +name = "radroots_studio_domain" +version = "0.1.0-alpha" source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3" dependencies = [ "bech32", @@ -1970,7 +1981,7 @@ dependencies = [ "directories", "quote", "radroots_studio_application", - "radroots_studio_domain", + "radroots_studio_domain 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)", "radroots_studio_nostr", "radroots_studio_runtime", "radroots_studio_storage", @@ -1989,7 +2000,7 @@ dependencies = [ "nostr-sdk 0.44.0", "radroots_identity", "radroots_studio_application", - "radroots_studio_domain", + "radroots_studio_domain 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)", "radroots_transport", "radroots_transport_nostr", "tokio", @@ -2008,7 +2019,7 @@ version = "0.1.0-alpha" source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3" dependencies = [ "radroots_studio_application", - "radroots_studio_domain", + "radroots_studio_domain 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)", "radroots_studio_nostr", "radroots_studio_storage", "tokio", @@ -2020,7 +2031,7 @@ name = "radroots_studio_source_lock" version = "0.1.0-alpha" dependencies = [ "radroots_studio_application", - "radroots_studio_domain", + "radroots_studio_domain 0.1.0-alpha", "radroots_studio_ffi", "radroots_studio_nostr", "radroots_studio_preferences", @@ -2038,7 +2049,7 @@ dependencies = [ "hmac", "keyring", "radroots_studio_application", - "radroots_studio_domain", + "radroots_studio_domain 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)", "refinery", "rusqlite", "rustix", diff --git a/core/Cargo.toml b/core/Cargo.toml @@ -1,5 +1,9 @@ [workspace] -members = ["crates/source_lock", "crates/studio_preferences"] +members = [ + "crates/source_lock", + "crates/studio_domain", + "crates/studio_preferences", +] resolver = "3" [workspace.package] @@ -20,9 +24,10 @@ pedantic = "deny" [workspace.dependencies] radroots_studio_application = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" } -radroots_studio_domain = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", 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_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 = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" } +radroots_identity = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha", default-features = false } diff --git a/core/crates/studio_domain/Cargo.toml b/core/crates/studio_domain/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "radroots_studio_domain" +description = "Private domain model 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] +bech32 = "=0.11.1" +radroots_identity.workspace = true +secrecy = "=0.10.3" +url = "=2.5.8" +zeroize = "=1.9.0" + +[lints] +workspace = true diff --git a/core/crates/studio_domain/src/account.rs b/core/crates/studio_domain/src/account.rs @@ -0,0 +1,430 @@ +//! Public account metadata and lifecycle values. + +use crate::time::UnixTimestamp; +use crate::{Npub, PublicKey, SafeError, SafeErrorCode, SafeMessage}; + +const MAX_ACCOUNT_LABEL_CHARS: usize = 80; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AccountIdentity { + public_key: PublicKey, + npub: Npub, +} + +impl AccountIdentity { + /// Constructs one canonical Nostr account identity and derives its npub. + /// + /// # Errors + /// + /// Returns a safe public-key error if canonical NIP-19 encoding fails. + pub fn derive(public_key: PublicKey) -> Result<Self, SafeError> { + Ok(Self { + public_key, + npub: Npub::derive(public_key)?, + }) + } + + /// Reconstitutes persisted identity only when its public forms agree. + /// + /// # Errors + /// + /// Returns a safe public-key error for a mismatched or malformed npub. + pub fn verify(public_key: PublicKey, npub: String) -> Result<Self, SafeError> { + Ok(Self { + public_key, + npub: Npub::verify(public_key, npub)?, + }) + } + + #[must_use] + pub const fn public_key(&self) -> PublicKey { + self.public_key + } + + #[must_use] + pub const fn npub(&self) -> &Npub { + &self.npub + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LocalSignerBinding { + account: PublicKey, + availability: BindingAvailability, +} + +impl LocalSignerBinding { + #[must_use] + pub const fn new(account: PublicKey, availability: BindingAvailability) -> Self { + Self { + account, + availability, + } + } + + #[must_use] + pub const fn account(self) -> PublicKey { + self.account + } + + #[must_use] + pub const fn availability(self) -> BindingAvailability { + self.availability + } + + #[must_use] + pub const fn repair_action(self) -> Option<BindingRepairAction> { + match self.availability { + BindingAvailability::Available => None, + BindingAvailability::CredentialMissing => Some(BindingRepairAction::ImportCredential), + BindingAvailability::StoreUnavailable => { + Some(BindingRepairAction::RetryCredentialStore) + } + } + } + + /// Records a missing credential after a successful store lookup. + /// + /// # Errors + /// + /// Returns a safe state error unless the binding was previously available. + pub fn mark_credential_missing(&mut self) -> Result<(), SafeError> { + self.transition( + BindingAvailability::Available, + BindingAvailability::CredentialMissing, + ) + } + + pub fn mark_store_unavailable(&mut self) { + self.availability = BindingAvailability::StoreUnavailable; + } + + /// Completes an explicit credential repair. + /// + /// # Errors + /// + /// Returns a safe state error unless a credential was missing. + pub fn repair_credential(&mut self) -> Result<(), SafeError> { + self.transition( + BindingAvailability::CredentialMissing, + BindingAvailability::Available, + ) + } + + /// Resolves a recovered store lookup to its observed credential state. + /// + /// # Errors + /// + /// Returns a safe state error unless the credential store was unavailable. + pub fn resolve_store_recovery(&mut self, credential_present: bool) -> Result<(), SafeError> { + if self.availability != BindingAvailability::StoreUnavailable { + return Err(invalid_account_metadata()); + } + self.availability = if credential_present { + BindingAvailability::Available + } else { + BindingAvailability::CredentialMissing + }; + Ok(()) + } + + fn transition( + &mut self, + expected: BindingAvailability, + next: BindingAvailability, + ) -> Result<(), SafeError> { + if self.availability != expected { + return Err(invalid_account_metadata()); + } + self.availability = next; + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BindingAvailability { + Available, + CredentialMissing, + StoreUnavailable, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BindingRepairAction { + ImportCredential, + RetryCredentialStore, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AccountLabel(String); + +impl AccountLabel { + /// Trims and validates an optional human-assigned account label value. + /// + /// # Errors + /// + /// Returns a safe metadata error when the resulting label is empty, too + /// long, or contains a control character. + pub fn parse(value: &str) -> Result<Self, SafeError> { + let normalized = value.trim(); + if normalized.is_empty() + || normalized.chars().count() > MAX_ACCOUNT_LABEL_CHARS + || normalized.chars().any(char::is_control) + { + return Err(invalid_account_metadata()); + } + Ok(Self(normalized.to_owned())) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct AccountCreatedAt(UnixTimestamp); + +impl AccountCreatedAt { + #[must_use] + pub const fn new(timestamp: UnixTimestamp) -> Self { + Self(timestamp) + } + + #[must_use] + pub const fn timestamp(self) -> UnixTimestamp { + self.0 + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AccountSummary { + identity: AccountIdentity, + signer: LocalSignerBinding, + label: Option<AccountLabel>, + created_at: AccountCreatedAt, + last_used_at: Option<UnixTimestamp>, +} + +impl AccountSummary { + /// Creates an account summary whose identity and signer binding refer to the same account. + /// + /// # Errors + /// + /// Returns an invalid-account-metadata error when the signer binding belongs to a different + /// public key. + pub fn new( + identity: AccountIdentity, + signer: LocalSignerBinding, + label: Option<AccountLabel>, + created_at: AccountCreatedAt, + last_used_at: Option<UnixTimestamp>, + ) -> Result<Self, SafeError> { + if identity.public_key() != signer.account() { + return Err(invalid_account_metadata()); + } + Ok(Self { + identity, + signer, + label, + created_at, + last_used_at, + }) + } + + #[must_use] + pub const fn public_key(&self) -> PublicKey { + self.identity.public_key() + } + + #[must_use] + pub fn npub(&self) -> &Npub { + self.identity.npub() + } + + #[must_use] + pub const fn signer(&self) -> LocalSignerBinding { + self.signer + } + + #[must_use] + pub fn label(&self) -> Option<&AccountLabel> { + self.label.as_ref() + } + + #[must_use] + pub const fn created_at(&self) -> AccountCreatedAt { + self.created_at + } + + #[must_use] + pub const fn last_used_at(&self) -> Option<UnixTimestamp> { + self.last_used_at + } + + #[must_use] + pub fn with_binding_availability(&self, availability: BindingAvailability) -> Self { + Self { + identity: self.identity.clone(), + signer: LocalSignerBinding::new(self.public_key(), availability), + label: self.label.clone(), + created_at: self.created_at, + last_used_at: self.last_used_at, + } + } + + #[must_use] + pub fn with_last_used_at(&self, last_used_at: UnixTimestamp) -> Self { + Self { + identity: self.identity.clone(), + signer: self.signer, + label: self.label.clone(), + created_at: self.created_at, + last_used_at: Some(last_used_at), + } + } + + #[must_use] + pub fn display_label(&self) -> String { + self.label + .as_ref() + .map_or_else(|| self.npub().short(), |label| label.as_str().to_owned()) + } +} + +const fn invalid_account_metadata() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidAccountMetadata, + SafeMessage::new("The account metadata is invalid."), + ) +} + +#[cfg(test)] +mod tests { + use crate::PublicKey; + use crate::time::UnixTimestamp; + + use super::{ + AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability, + BindingRepairAction, LocalSignerBinding, + }; + + const DERIVED_NPUB: &str = "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7"; + const MISMATCHED_NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg"; + + fn public_key() -> PublicKey { + PublicKey::from_bytes([7_u8; 32]).expect("valid public key") + } + + fn account(label: Option<AccountLabel>) -> AccountSummary { + let public_key = public_key(); + AccountSummary::new( + AccountIdentity::derive(public_key).expect("identity"), + LocalSignerBinding::new(public_key, BindingAvailability::Available), + label, + AccountCreatedAt::new(UnixTimestamp::from_seconds(10).expect("valid time")), + None, + ) + .expect("account") + } + + #[test] + fn account_label_is_trimmed_bounded_and_control_free() { + let label = AccountLabel::parse(" Farm account ").expect("valid label"); + assert_eq!(label.as_str(), "Farm account"); + + for invalid in ["", " ", "line\nbreak", &"x".repeat(81)] { + assert!(AccountLabel::parse(invalid).is_err()); + } + } + + #[test] + fn account_display_prefers_label_then_shortened_npub() { + let labelled = account(Some(AccountLabel::parse("Farm").expect("valid label"))); + let unlabelled = account(None); + + assert_eq!(labelled.display_label(), "Farm"); + assert_eq!(unlabelled.display_label(), "npub1qurswpc…rsnvjvl7"); + } + + #[test] + fn local_account_summary_contains_public_metadata_only() { + let account = account(None); + let debug = format!("{account:?}"); + + assert_eq!( + account.signer().availability(), + BindingAvailability::Available + ); + assert!(account.label().is_none()); + assert!(account.last_used_at().is_none()); + assert_eq!(account.created_at().timestamp().as_seconds(), 10); + assert_eq!(account.public_key(), public_key()); + assert_eq!(account.npub().as_str(), DERIVED_NPUB); + assert!(!debug.contains("nsec1")); + assert!(!debug.contains(&"11".repeat(32))); + } + + #[test] + fn account_identity_derives_npub_and_rejects_mismatched_persisted_forms() { + let public_key = public_key(); + let identity = AccountIdentity::derive(public_key).expect("identity"); + assert_eq!(identity.public_key(), public_key); + assert_eq!(identity.npub().as_str(), DERIVED_NPUB); + assert_eq!( + AccountIdentity::verify(public_key, DERIVED_NPUB.to_owned()).expect("verified"), + identity + ); + assert!(AccountIdentity::verify(public_key, MISMATCHED_NPUB.to_owned()).is_err()); + assert!( + AccountIdentity::verify( + PublicKey::from_hex( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + ) + .expect("second public key"), + MISMATCHED_NPUB.to_owned() + ) + .is_err() + ); + } + + #[test] + fn local_signer_binding_carries_only_canonical_account_identity() { + let public_key = public_key(); + let identity = AccountIdentity::derive(public_key).expect("identity"); + let binding = LocalSignerBinding::new(public_key, BindingAvailability::Available); + + assert_eq!(binding.account(), identity.public_key()); + assert!(!format!("{binding:?}").contains("nsec1")); + } + + #[test] + fn local_binding_repair_transitions_are_typed_and_fail_closed() { + let public_key = public_key(); + let mut binding = LocalSignerBinding::new(public_key, BindingAvailability::Available); + assert_eq!(binding.repair_action(), None); + assert!(binding.repair_credential().is_err()); + + binding + .mark_credential_missing() + .expect("missing credential"); + assert_eq!( + binding.repair_action(), + Some(BindingRepairAction::ImportCredential) + ); + binding.repair_credential().expect("repair"); + + binding.mark_store_unavailable(); + assert_eq!( + binding.repair_action(), + Some(BindingRepairAction::RetryCredentialStore) + ); + binding + .resolve_store_recovery(false) + .expect("store recovery"); + assert_eq!( + binding.availability(), + BindingAvailability::CredentialMissing + ); + assert!(binding.resolve_store_recovery(true).is_err()); + } +} diff --git a/core/crates/studio_domain/src/error.rs b/core/crates/studio_domain/src/error.rs @@ -0,0 +1,113 @@ +use std::error::Error; +use std::fmt::{self, Debug, Display, Formatter}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SafeErrorCode { + InvalidPublicKey, + InvalidSecretKey, + InvalidAccountMetadata, + InvalidProfileMetadata, + InvalidApplicationState, + AccountAlreadyExists, + AccountNotFound, + KeyringUnavailable, + CredentialMissing, + StorageUnavailable, + StorageCorrupt, + StorageQuarantined, + StorageBackupInvalid, + UnsupportedSchemaVersion, + RepairUnauthorized, + PendingOperationRecoveryRequired, + InvalidRelayConfiguration, + RelayConnectionFailed, + ProfileRefreshFailed, + ObserverRegistrationFailed, + NativeLibraryLoadFailed, +} + +#[derive(Clone, Copy, Eq, PartialEq)] +pub struct SafeMessage(&'static str); + +impl SafeMessage { + #[must_use] + pub const fn new(message: &'static str) -> Self { + Self(message) + } + + #[must_use] + pub const fn as_str(self) -> &'static str { + self.0 + } +} + +impl Debug for SafeMessage { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.debug_tuple("SafeMessage").field(&self.0).finish() + } +} + +impl Display for SafeMessage { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(self.0) + } +} + +#[derive(Clone, Copy, Eq, PartialEq)] +pub struct SafeError { + code: SafeErrorCode, + message: SafeMessage, +} + +impl SafeError { + #[must_use] + pub const fn new(code: SafeErrorCode, message: SafeMessage) -> Self { + Self { code, message } + } + + #[must_use] + pub const fn code(self) -> SafeErrorCode { + self.code + } + + #[must_use] + pub const fn message(self) -> SafeMessage { + self.message + } +} + +impl Debug for SafeError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SafeError") + .field("code", &self.code) + .field("message", &self.message) + .finish() + } +} + +impl Display for SafeError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + Display::fmt(&self.message, formatter) + } +} + +impl Error for SafeError {} + +#[cfg(test)] +mod tests { + use super::{SafeError, SafeErrorCode, SafeMessage}; + + #[test] + fn safe_error_formats_only_a_static_public_message() { + let error = SafeError::new( + SafeErrorCode::InvalidSecretKey, + SafeMessage::new("The secret key is invalid."), + ); + + assert_eq!(error.to_string(), "The secret key is invalid."); + assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey); + assert_eq!(error.message().as_str(), "The secret key is invalid."); + assert!(!format!("{error:?}").contains("nsec1unsafe-test-value")); + } +} diff --git a/core/crates/studio_domain/src/key.rs b/core/crates/studio_domain/src/key.rs @@ -0,0 +1,451 @@ +//! Validated Nostr public and secret-key boundary values. + +use std::fmt::{self, Display, Formatter}; +use std::str::FromStr; + +use secrecy::{ExposeSecret, SecretString}; +use zeroize::Zeroizing; + +use crate::{SafeError, SafeErrorCode, SafeMessage}; + +pub const PUBLIC_KEY_BYTE_LENGTH: usize = 32; +pub const PUBLIC_KEY_HEX_LENGTH: usize = PUBLIC_KEY_BYTE_LENGTH * 2; +pub const MAX_SECRET_KEY_INPUT_BYTES: usize = 128; +const NIP19_KEY_LENGTH: usize = 63; +const BECH32_DATA_CHARSET: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct Npub(String); + +impl Npub { + /// Constructs a human-facing npub after structural validation. + /// + /// Cryptographic conversion and checksum validation are performed by the + /// selected Nostr adapter before this domain value is created in runtime + /// flows. + /// + /// # Errors + /// + /// Returns a safe invalid-public-key error for a malformed npub shape. + pub fn from_encoded(value: String) -> Result<Self, SafeError> { + if !is_nip19_key_shape(&value, "npub1") { + return Err(invalid_public_key()); + } + Ok(Self(value)) + } + + /// Derives the canonical NIP-19 display identity from a public key. + /// + /// # Errors + /// + /// Returns a safe public-key error if canonical encoding fails. + pub fn derive(public_key: PublicKey) -> Result<Self, SafeError> { + let hrp = bech32::Hrp::parse("npub").map_err(|_| invalid_public_key())?; + bech32::encode::<bech32::Bech32>(hrp, public_key.as_bytes()) + .map_err(|_| invalid_public_key()) + .and_then(Self::from_encoded) + } + + /// Validates that encoded display identity belongs to the canonical key. + /// + /// # Errors + /// + /// Returns a safe public-key error when the values do not match. + pub fn verify(public_key: PublicKey, encoded: String) -> Result<Self, SafeError> { + let candidate = Self::from_encoded(encoded)?; + if candidate != Self::derive(public_key)? { + return Err(invalid_public_key()); + } + Ok(candidate) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + #[must_use] + pub fn short(&self) -> String { + format!("{}…{}", &self.0[..12], &self.0[self.0.len() - 8..]) + } +} + +impl Display for Npub { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +pub struct Nsec(SecretString); + +impl Nsec { + /// Constructs a secret nsec display value after structural validation. + /// + /// # Errors + /// + /// Returns a safe invalid-secret-key error for a malformed nsec shape. + pub fn from_encoded(value: String) -> Result<Self, SafeError> { + if !is_nip19_key_shape(&value, "nsec1") { + return Err(invalid_secret_key()); + } + Ok(Self(SecretString::from(value))) + } + + pub fn with_exposed_secret<T>(&self, operation: impl FnOnce(&str) -> T) -> T { + operation(self.0.expose_secret()) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SecretKeyInputKind { + Nsec, + Hex, +} + +pub struct SecretKeyInput { + value: SecretString, + kind: SecretKeyInputKind, +} + +impl SecretKeyInput { + /// Moves bounded transport bytes into the zeroizing secret boundary. + /// + /// The source byte allocation is cleared on every return path. + /// + /// # Errors + /// + /// Returns a safe invalid-secret-key error for oversized, non-UTF-8, or + /// structurally invalid input. + pub fn parse_bytes(value: Vec<u8>) -> Result<Self, SafeError> { + let value = Zeroizing::new(value); + if value.len() > MAX_SECRET_KEY_INPUT_BYTES { + return Err(invalid_secret_key()); + } + let encoded = std::str::from_utf8(&value).map_err(|_| invalid_secret_key())?; + Self::parse(encoded.to_owned()) + } + + /// Moves one secret input string into a zeroizing boundary. + /// + /// Nsec inputs receive complete NIP-19 validation in the Nostr adapter. + /// Hex input is structurally validated here to prevent ambiguous fallback. + /// + /// # Errors + /// + /// Returns a safe invalid-secret-key error when the input is neither an + /// nsec-looking value nor exactly 64 lowercase hexadecimal characters. + pub fn parse(value: String) -> Result<Self, SafeError> { + let mut value = Zeroizing::new(value); + let kind = if value.len() == PUBLIC_KEY_HEX_LENGTH + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + SecretKeyInputKind::Hex + } else if is_nip19_key_shape(&value, "nsec1") { + SecretKeyInputKind::Nsec + } else { + return Err(invalid_secret_key()); + }; + + Ok(Self { + value: SecretString::from(std::mem::take(&mut *value)), + kind, + }) + } + + #[must_use] + pub const fn kind(&self) -> SecretKeyInputKind { + self.kind + } + + pub fn with_exposed_secret<T>(&self, operation: impl FnOnce(&str) -> T) -> T { + operation(self.value.expose_secret()) + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct PublicKey(radroots_identity::PublicKey); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PersistedPublicKeyClassification { + Canonical(PublicKey), + NonCanonicalEncoding, + InvalidCurvePoint, + MalformedEncoding, +} + +impl PublicKey { + /// Validates canonical x-only secp256k1 public-key bytes. + /// + /// # Errors + /// + /// Returns a safe invalid-public-key error when the bytes are not a valid + /// x-only secp256k1 point. + pub fn from_bytes(bytes: [u8; PUBLIC_KEY_BYTE_LENGTH]) -> Result<Self, SafeError> { + radroots_identity::PublicKey::from_bytes(bytes) + .map(Self) + .map_err(|_| invalid_public_key()) + } + + /// Parses a canonical lowercase hexadecimal Nostr public key. + /// + /// # Errors + /// + /// Returns a safe invalid-public-key error when the value is not exactly + /// 64 lowercase hexadecimal characters. + pub fn from_hex(value: &str) -> Result<Self, SafeError> { + if value.len() != PUBLIC_KEY_HEX_LENGTH + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(invalid_public_key()); + } + + radroots_identity::PublicKey::from_hex(value) + .map(Self) + .map_err(|_| invalid_public_key()) + } + + #[must_use] + pub const fn as_bytes(&self) -> &[u8; PUBLIC_KEY_BYTE_LENGTH] { + self.0.as_bytes() + } + + #[must_use] + pub const fn canonical(self) -> radroots_identity::PublicKey { + self.0 + } + + #[must_use] + pub const fn from_canonical(public_key: radroots_identity::PublicKey) -> Self { + Self(public_key) + } + + #[must_use] + pub fn to_hex(self) -> String { + self.0.to_hex() + } + + #[must_use] + pub fn short_hex(self) -> String { + let hex = self.to_hex(); + format!("{}…{}", &hex[..8], &hex[PUBLIC_KEY_HEX_LENGTH - 8..]) + } +} + +impl Display for PublicKey { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.to_hex()) + } +} + +impl From<radroots_identity::PublicKey> for PublicKey { + fn from(value: radroots_identity::PublicKey) -> Self { + Self::from_canonical(value) + } +} + +impl From<PublicKey> for radroots_identity::PublicKey { + fn from(value: PublicKey) -> Self { + value.canonical() + } +} + +impl FromStr for PublicKey { + type Err = SafeError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::from_hex(value) + } +} + +const fn invalid_public_key() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidPublicKey, + SafeMessage::new("The Nostr public key is invalid."), + ) +} + +const fn invalid_secret_key() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidSecretKey, + SafeMessage::new("The Nostr secret key is invalid."), + ) +} + +#[must_use] +pub fn classify_persisted_public_key(value: &str) -> PersistedPublicKeyClassification { + if value.len() != PUBLIC_KEY_HEX_LENGTH || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return PersistedPublicKeyClassification::MalformedEncoding; + } + if !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return PersistedPublicKeyClassification::NonCanonicalEncoding; + } + match PublicKey::from_hex(value) { + Ok(public_key) => PersistedPublicKeyClassification::Canonical(public_key), + Err(_) => PersistedPublicKeyClassification::InvalidCurvePoint, + } +} + +fn is_nip19_key_shape(value: &str, prefix: &str) -> bool { + value.len() == NIP19_KEY_LENGTH + && value.starts_with(prefix) + && value[prefix.len()..] + .bytes() + .all(|byte| BECH32_DATA_CHARSET.contains(&byte)) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::{ + MAX_SECRET_KEY_INPUT_BYTES, Npub, Nsec, PUBLIC_KEY_BYTE_LENGTH, + PersistedPublicKeyClassification, PublicKey, SecretKeyInput, SecretKeyInputKind, + classify_persisted_public_key, + }; + use crate::SafeErrorCode; + + const HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; + const NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg"; + const NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"; + + #[test] + fn public_key_round_trips_canonical_hex_and_bytes() { + let key = PublicKey::from_str(HEX).expect("valid public key"); + + assert_eq!(key.to_hex(), HEX); + assert_eq!(key.to_string(), HEX); + assert_eq!(key.short_hex(), "7e7e9c42…2107f6d7"); + assert_eq!( + PublicKey::from_bytes(*key.as_bytes()).expect("valid bytes"), + key + ); + assert_eq!(key.as_bytes().len(), PUBLIC_KEY_BYTE_LENGTH); + } + + #[test] + fn public_key_rejects_noncanonical_or_malformed_hex() { + for value in [ + "", + "00", + "7E7E9C42A91BFEF19FA7EA99D52D8AFDB67D893A8FEFBA1F5CB9793F2107F6D7", + "ze7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + " 7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + "00e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + ] { + let error = PublicKey::from_hex(value).expect_err("invalid public key"); + assert_eq!(error.code(), SafeErrorCode::InvalidPublicKey); + } + } + + #[test] + fn public_keys_are_ordered_by_canonical_bytes() { + let low = + PublicKey::from_hex("585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df") + .expect("low key"); + let high = + PublicKey::from_hex("e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af") + .expect("high key"); + + assert!(low < high); + } + + #[test] + fn persisted_public_key_classification_is_explicit_and_fail_closed() { + assert!(matches!( + classify_persisted_public_key(HEX), + PersistedPublicKeyClassification::Canonical(_) + )); + assert_eq!( + classify_persisted_public_key(HEX.to_ascii_uppercase().as_str()), + PersistedPublicKeyClassification::NonCanonicalEncoding + ); + assert_eq!( + classify_persisted_public_key(&"00".repeat(PUBLIC_KEY_BYTE_LENGTH)), + PersistedPublicKeyClassification::InvalidCurvePoint + ); + assert_eq!( + classify_persisted_public_key("not-a-public-key"), + PersistedPublicKeyClassification::MalformedEncoding + ); + } + + #[test] + fn secret_input_is_redacted_and_exposed_only_to_a_scoped_operation() { + let secret = "11".repeat(PUBLIC_KEY_BYTE_LENGTH); + let input = SecretKeyInput::parse(secret.clone()).expect("valid secret hex"); + + assert_eq!(input.kind(), SecretKeyInputKind::Hex); + assert_eq!(input.with_exposed_secret(str::len), secret.len()); + assert_eq!(input.with_exposed_secret(str::len), 64); + } + + #[test] + fn secret_input_accepts_nsec_shape_without_exposing_it() { + let secret = NSEC.to_owned(); + let input = SecretKeyInput::parse(secret.clone()).expect("nsec-shaped input"); + + assert_eq!(input.kind(), SecretKeyInputKind::Nsec); + assert_eq!(input.with_exposed_secret(str::len), secret.len()); + } + + #[test] + fn secret_input_rejects_invalid_hex_and_arbitrary_text() { + for value in [ + "", + "very-sensitive-input", + &"GG".repeat(PUBLIC_KEY_BYTE_LENGTH), + ] { + let Err(error) = SecretKeyInput::parse(value.to_owned()) else { + panic!("invalid secret accepted"); + }; + assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey); + if !value.is_empty() { + assert!(!format!("{error:?}").contains(value)); + } + } + } + + #[test] + fn secret_byte_transport_is_bounded_and_validated() { + let parsed = SecretKeyInput::parse_bytes(HEX.as_bytes().to_vec()).expect("bytes"); + assert_eq!(parsed.with_exposed_secret(str::len), 64); + assert!(SecretKeyInput::parse_bytes(vec![0xff]).is_err()); + assert!(SecretKeyInput::parse_bytes(vec![b'a'; MAX_SECRET_KEY_INPUT_BYTES + 1]).is_err()); + } + + #[test] + fn npub_is_public_display_data_but_not_canonical_identity() { + let npub = Npub::from_encoded(NPUB.to_owned()).expect("valid npub shape"); + + assert_eq!(npub.as_str(), NPUB); + assert_eq!(npub.to_string(), NPUB); + } + + #[test] + fn nsec_is_redacted_and_exposed_only_to_a_scoped_operation() { + let nsec = Nsec::from_encoded(NSEC.to_owned()).expect("valid nsec shape"); + + assert_eq!(nsec.with_exposed_secret(str::len), NSEC.len()); + assert_eq!(nsec.with_exposed_secret(str::len), NSEC.len()); + } + + #[test] + fn nip19_display_types_reject_wrong_prefix_length_and_charset() { + for invalid in [ + "", + "npub1short", + "nsec1short", + "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjp!g", + ] { + assert!(Npub::from_encoded(invalid.to_owned()).is_err()); + assert!(Nsec::from_encoded(invalid.to_owned()).is_err()); + } + } +} diff --git a/core/crates/studio_domain/src/lib.rs b/core/crates/studio_domain/src/lib.rs @@ -0,0 +1,21 @@ +#![doc = "Radroots Studio Nostr account domain types."] + +pub mod account; +pub mod error; +pub mod key; +pub mod profile; +pub mod relay; +pub mod time; + +pub use account::{ + AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability, + BindingRepairAction, LocalSignerBinding, +}; +pub use error::{SafeError, SafeErrorCode, SafeMessage}; +pub use key::{ + MAX_SECRET_KEY_INPUT_BYTES, Npub, Nsec, PersistedPublicKeyClassification, PublicKey, + SecretKeyInput, SecretKeyInputKind, classify_persisted_public_key, +}; +pub use profile::{EventId, Kind0ProfileCandidate, ProfileMetadata, select_latest_kind0}; +pub use relay::{RelayDestinationPolicy, RelayUrl, normalize_relay_urls}; +pub use time::UnixTimestamp; diff --git a/core/crates/studio_domain/src/profile.rs b/core/crates/studio_domain/src/profile.rs @@ -0,0 +1,298 @@ +//! Public Nostr profile metadata values. + +use crate::{PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp}; + +const EVENT_ID_BYTES: usize = 32; +const EVENT_ID_HEX: usize = EVENT_ID_BYTES * 2; +const MAX_NAME_CHARS: usize = 128; +const MAX_NIP05_CHARS: usize = 320; +const MAX_ABOUT_CHARS: usize = 4_096; +const MAX_PICTURE_CHARS: usize = 2_048; + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct EventId([u8; EVENT_ID_BYTES]); + +impl EventId { + /// Parses a canonical lowercase hexadecimal Nostr event ID. + /// + /// # Errors + /// + /// Returns a safe profile error for malformed input. + pub fn from_hex(value: &str) -> Result<Self, SafeError> { + if value.len() != EVENT_ID_HEX + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(invalid_profile_metadata()); + } + + let mut bytes = [0_u8; EVENT_ID_BYTES]; + for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { + let high = decode_hex(pair[0]).ok_or_else(invalid_profile_metadata)?; + let low = decode_hex(pair[1]).ok_or_else(invalid_profile_metadata)?; + bytes[index] = (high << 4) | low; + } + Ok(Self(bytes)) + } + + #[must_use] + pub const fn from_bytes(bytes: [u8; EVENT_ID_BYTES]) -> Self { + Self(bytes) + } + + #[must_use] + pub const fn as_bytes(self) -> [u8; EVENT_ID_BYTES] { + self.0 + } + + #[must_use] + pub fn to_hex(self) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(EVENT_ID_HEX); + for byte in self.0 { + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + output + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ProfileMetadata { + name: Option<String>, + display_name: Option<String>, + nip05: Option<String>, + about: Option<String>, + picture: Option<String>, +} + +impl ProfileMetadata { + /// Normalizes and bounds public kind-0 profile fields. + /// + /// # Errors + /// + /// Returns a safe profile error when a field exceeds its limit or contains + /// a forbidden control character. + pub fn new( + name: Option<String>, + display_name: Option<String>, + nip05: Option<String>, + about: Option<String>, + picture: Option<String>, + ) -> Result<Self, SafeError> { + Ok(Self { + name: normalize_field(name, MAX_NAME_CHARS, false)?, + display_name: normalize_field(display_name, MAX_NAME_CHARS, false)?, + nip05: normalize_field(nip05, MAX_NIP05_CHARS, false)?, + about: normalize_field(about, MAX_ABOUT_CHARS, true)?, + picture: normalize_field(picture, MAX_PICTURE_CHARS, false)?, + }) + } + + #[must_use] + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + #[must_use] + pub fn display_name(&self) -> Option<&str> { + self.display_name.as_deref() + } + + #[must_use] + pub fn nip05(&self) -> Option<&str> { + self.nip05.as_deref() + } + + #[must_use] + pub fn about(&self) -> Option<&str> { + self.about.as_deref() + } + + #[must_use] + pub fn picture(&self) -> Option<&str> { + self.picture.as_deref() + } + + #[must_use] + pub fn preferred_name(&self) -> Option<&str> { + self.display_name().or_else(|| self.name()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Kind0ProfileCandidate { + event_id: EventId, + author: PublicKey, + created_at: UnixTimestamp, + metadata: ProfileMetadata, +} + +impl Kind0ProfileCandidate { + #[must_use] + pub const fn new( + event_id: EventId, + author: PublicKey, + created_at: UnixTimestamp, + metadata: ProfileMetadata, + ) -> Self { + Self { + event_id, + author, + created_at, + metadata, + } + } + + #[must_use] + pub const fn event_id(&self) -> EventId { + self.event_id + } + + #[must_use] + pub const fn author(&self) -> PublicKey { + self.author + } + + #[must_use] + pub const fn created_at(&self) -> UnixTimestamp { + self.created_at + } + + #[must_use] + pub const fn metadata(&self) -> &ProfileMetadata { + &self.metadata + } +} + +#[must_use] +pub fn select_latest_kind0( + candidates: impl IntoIterator<Item = Kind0ProfileCandidate>, +) -> Option<Kind0ProfileCandidate> { + candidates.into_iter().reduce(|selected, candidate| { + if candidate.created_at > selected.created_at + || (candidate.created_at == selected.created_at + && candidate.event_id < selected.event_id) + { + candidate + } else { + selected + } + }) +} + +fn normalize_field( + value: Option<String>, + max_chars: usize, + allow_layout_controls: bool, +) -> Result<Option<String>, SafeError> { + let Some(value) = value else { + return Ok(None); + }; + let normalized = value.trim(); + if normalized.is_empty() { + return Ok(None); + } + if normalized.chars().count() > max_chars + || normalized.chars().any(|character| { + character.is_control() + && !(allow_layout_controls && matches!(character, '\n' | '\r' | '\t')) + }) + { + return Err(invalid_profile_metadata()); + } + Ok(Some(normalized.to_owned())) +} + +const fn invalid_profile_metadata() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidProfileMetadata, + SafeMessage::new("The Nostr profile metadata is invalid."), + ) +} + +const fn decode_hex(byte: u8) -> Option<u8> { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use crate::{PublicKey, UnixTimestamp}; + + use super::{EventId, Kind0ProfileCandidate, ProfileMetadata, select_latest_kind0}; + + fn profile(name: &str) -> ProfileMetadata { + ProfileMetadata::new(Some(name.to_owned()), None, None, None, None).expect("valid profile") + } + + fn candidate(id_byte: u8, created_at: i64, name: &str) -> Kind0ProfileCandidate { + Kind0ProfileCandidate::new( + EventId::from_bytes([id_byte; 32]), + PublicKey::from_bytes([7_u8; 32]).expect("valid public key"), + UnixTimestamp::from_seconds(created_at).expect("valid timestamp"), + profile(name), + ) + } + + #[test] + fn profile_fields_are_trimmed_bounded_and_public() { + let metadata = ProfileMetadata::new( + Some(" farmer ".to_owned()), + Some(" Farm Account ".to_owned()), + Some("farmer@example.test".to_owned()), + Some("First line\nSecond line".to_owned()), + Some("https://images.example.test/profile.png".to_owned()), + ) + .expect("valid profile"); + + assert_eq!(metadata.name(), Some("farmer")); + assert_eq!(metadata.display_name(), Some("Farm Account")); + assert_eq!(metadata.preferred_name(), Some("Farm Account")); + assert_eq!(metadata.nip05(), Some("farmer@example.test")); + assert_eq!(metadata.about(), Some("First line\nSecond line")); + assert_eq!( + metadata.picture(), + Some("https://images.example.test/profile.png") + ); + } + + #[test] + fn profile_fields_reject_forbidden_controls_and_oversize_values() { + assert!( + ProfileMetadata::new(Some("bad\0name".to_owned()), None, None, None, None).is_err() + ); + assert!(ProfileMetadata::new(Some("x".repeat(129)), None, None, None, None).is_err()); + } + + #[test] + fn latest_kind0_uses_timestamp_then_lowest_event_id() { + let older = candidate(0, 10, "older"); + let equal_high_id = candidate(9, 20, "high-id"); + let equal_low_id = candidate(1, 20, "low-id"); + + let selected = + select_latest_kind0([older, equal_high_id, equal_low_id]).expect("selected profile"); + + assert_eq!(selected.metadata().name(), Some("low-id")); + assert_eq!(selected.event_id().as_bytes(), [1_u8; 32]); + assert_eq!( + selected.author(), + PublicKey::from_bytes([7_u8; 32]).expect("valid public key") + ); + assert_eq!(selected.created_at().as_seconds(), 20); + } + + #[test] + fn event_id_rejects_noncanonical_hex_and_round_trips() { + let hex = "12".repeat(32); + let event_id = EventId::from_hex(&hex).expect("valid event id"); + + assert_eq!(event_id.to_hex(), hex); + assert!(EventId::from_hex(&"GG".repeat(32)).is_err()); + } +} diff --git a/core/crates/studio_domain/src/relay.rs b/core/crates/studio_domain/src/relay.rs @@ -0,0 +1,198 @@ +//! Validated Nostr relay values. + +use std::collections::HashSet; +use std::fmt::{self, Display, Formatter}; + +use url::{Host, Url}; + +use crate::{SafeError, SafeErrorCode, SafeMessage}; + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum RelayDestinationPolicy { + Public, + Local, + PrivateNetwork, +} + +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct RelayUrl { + value: String, + policy: RelayDestinationPolicy, +} + +impl RelayUrl { + /// Parses and normalizes an allowed WebSocket relay URL. + /// + /// # Errors + /// + /// Returns a safe configuration error for empty or malformed input, + /// forbidden schemes, credentials, fragments, or non-loopback `ws://`. + pub fn parse(value: &str, policy: RelayDestinationPolicy) -> Result<Self, SafeError> { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed.chars().any(char::is_control) { + return Err(invalid_relay()); + } + + let parsed = Url::parse(trimmed).map_err(|_| invalid_relay())?; + if !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.fragment().is_some() + { + return Err(invalid_relay()); + } + + match (policy, parsed.scheme()) { + (RelayDestinationPolicy::Public | RelayDestinationPolicy::PrivateNetwork, "wss") => {} + (RelayDestinationPolicy::Local, "ws" | "wss") if is_loopback(&parsed) => {} + _ => return Err(invalid_relay()), + } + + if parsed.host().is_none() { + return Err(invalid_relay()); + } + + Ok(Self { + value: parsed.to_string(), + policy, + }) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.value + } + + #[must_use] + pub const fn policy(&self) -> RelayDestinationPolicy { + self.policy + } +} + +impl Display for RelayUrl { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.value) + } +} + +/// Parses relay values and removes duplicates without changing first-seen order. +/// +/// # Errors +/// +/// Returns the first safe relay validation error. +pub fn normalize_relay_urls<I, S>( + values: I, + policy: RelayDestinationPolicy, +) -> Result<Vec<RelayUrl>, SafeError> +where + I: IntoIterator<Item = S>, + S: AsRef<str>, +{ + let mut seen = HashSet::new(); + let mut relays = Vec::new(); + for value in values { + let relay = RelayUrl::parse(value.as_ref(), policy)?; + if seen.insert(relay.clone()) { + relays.push(relay); + } + } + Ok(relays) +} + +fn is_loopback(url: &Url) -> bool { + match url.host() { + Some(Host::Domain(domain)) => domain == "localhost", + Some(Host::Ipv4(address)) => address.octets()[0] == 127, + Some(Host::Ipv6(address)) => address.is_loopback(), + None => false, + } +} + +const fn invalid_relay() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidRelayConfiguration, + SafeMessage::new("The Nostr relay URL is invalid."), + ) +} + +#[cfg(test)] +mod tests { + use super::{RelayDestinationPolicy, RelayUrl, normalize_relay_urls}; + use crate::SafeErrorCode; + + #[test] + fn relay_accepts_secure_remote_and_loopback_development_urls() { + for (input, policy, expected) in [ + ( + " wss://Relay.Example/path ", + RelayDestinationPolicy::Public, + "wss://relay.example/path", + ), + ( + "ws://localhost:8080", + RelayDestinationPolicy::Local, + "ws://localhost:8080/", + ), + ( + "ws://127.42.1.9:8080", + RelayDestinationPolicy::Local, + "ws://127.42.1.9:8080/", + ), + ( + "ws://[::1]:8080", + RelayDestinationPolicy::Local, + "ws://[::1]:8080/", + ), + ] { + let relay = RelayUrl::parse(input, policy).expect("allowed relay"); + assert_eq!(relay.as_str(), expected); + assert_eq!(relay.to_string(), expected); + } + } + + #[test] + fn relay_rejects_non_websocket_credentials_fragments_and_remote_plaintext() { + for input in [ + "", + "https://relay.example", + "http://localhost:8080", + "wss://user:password@relay.example", + "wss://relay.example/#fragment", + "ws://relay.example", + "ws://192.168.1.2:8080", + "ws://localhost.evil.example:8080", + "wss://relay.example/\nunsafe", + ] { + let error = RelayUrl::parse(input, RelayDestinationPolicy::Public) + .expect_err("forbidden relay"); + assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration); + } + } + + #[test] + fn relay_deduplication_preserves_normalized_first_seen_order() { + let relays = normalize_relay_urls( + [ + "wss://relay.example", + " wss://second.example/path ", + "wss://RELAY.example/", + "wss://second.example/path", + ], + RelayDestinationPolicy::Public, + ) + .expect("valid relays"); + + assert_eq!( + relays.iter().map(RelayUrl::as_str).collect::<Vec<_>>(), + vec!["wss://relay.example/", "wss://second.example/path"] + ); + } + + #[test] + fn relay_destination_policy_is_explicit_and_fail_closed() { + assert!(RelayUrl::parse("ws://localhost:8080", RelayDestinationPolicy::Public).is_err()); + assert!(RelayUrl::parse("wss://relay.example", RelayDestinationPolicy::Local).is_err()); + let private = RelayUrl::parse("wss://10.0.0.4", RelayDestinationPolicy::PrivateNetwork) + .expect("explicit private network"); + assert_eq!(private.policy(), RelayDestinationPolicy::PrivateNetwork); + } +} diff --git a/core/crates/studio_domain/src/time.rs b/core/crates/studio_domain/src/time.rs @@ -0,0 +1,36 @@ +//! Time values shared by account and profile records. + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct UnixTimestamp(i64); + +impl UnixTimestamp { + pub const UNIX_EPOCH: Self = Self(0); + + #[must_use] + pub const fn from_seconds(seconds: i64) -> Option<Self> { + if seconds < 0 { + None + } else { + Some(Self(seconds)) + } + } + + #[must_use] + pub const fn as_seconds(self) -> i64 { + self.0 + } +} + +#[cfg(test)] +mod tests { + use super::UnixTimestamp; + + #[test] + fn timestamp_rejects_negative_seconds() { + assert_eq!(UnixTimestamp::from_seconds(-1), None); + assert_eq!( + UnixTimestamp::from_seconds(0).map(UnixTimestamp::as_seconds), + Some(0) + ); + } +}