app

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

commit 14c3af9a7aa3974d86c405b487257b5d6ecf1770
parent 1509ee154f3e4af29d3a87e9424cfbe726e35b3a
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 22:05:21 +0000

domain: introduce canonical account identity

- derive npub display identity directly from canonical public-key bytes
- verify persisted public-key and npub forms before reconstruction
- reject structurally valid but mismatched account identities
- cover canonical derivation and corruption cases with fixed vectors

Diffstat:
Mcore/Cargo.lock | 1+
Mcore/Cargo.toml | 1+
Mcore/crates/domain/Cargo.toml | 1+
Mcore/crates/domain/src/account.rs | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcore/crates/domain/src/key.rs | 25+++++++++++++++++++++++++
Mcore/crates/domain/src/lib.rs | 4+++-
6 files changed, 94 insertions(+), 2 deletions(-)

diff --git a/core/Cargo.lock b/core/Cargo.lock @@ -1724,6 +1724,7 @@ dependencies = [ name = "radroots-studio-domain" version = "0.1.0-alpha" dependencies = [ + "bech32", "secrecy", "url", ] diff --git a/core/Cargo.toml b/core/Cargo.toml @@ -24,6 +24,7 @@ all = "deny" pedantic = "deny" [workspace.dependencies] +bech32 = "=0.11.1" keyring = "=4.1.6" directories = "=6.0.0" nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr" } diff --git a/core/crates/domain/Cargo.toml b/core/crates/domain/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true repository.workspace = true [dependencies] +bech32.workspace = true secrecy.workspace = true url.workspace = true diff --git a/core/crates/domain/src/account.rs b/core/crates/domain/src/account.rs @@ -5,6 +5,48 @@ 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 enum SignerKind { LocalSecret, @@ -176,9 +218,13 @@ mod tests { use crate::time::UnixTimestamp; use crate::{Npub, PublicKey}; - use super::{AccountCreatedAt, AccountLabel, AccountSummary, KeyAvailability, SignerKind}; + use super::{ + AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, KeyAvailability, + SignerKind, + }; const NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg"; + const DERIVED_NPUB: &str = "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7"; fn account(label: Option<AccountLabel>) -> AccountSummary { AccountSummary::new( @@ -226,4 +272,20 @@ mod tests { assert!(!debug.contains("nsec1")); assert!(!debug.contains(&"11".repeat(32))); } + + #[test] + fn account_identity_derives_npub_and_rejects_mismatched_persisted_forms() { + let public_key = PublicKey::from_bytes([7_u8; 32]); + 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, NPUB.to_owned()).is_err()); + assert!( + AccountIdentity::verify(PublicKey::from_bytes([8_u8; 32]), NPUB.to_owned()).is_err() + ); + } } diff --git a/core/crates/domain/src/key.rs b/core/crates/domain/src/key.rs @@ -32,6 +32,31 @@ impl Npub { 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 diff --git a/core/crates/domain/src/lib.rs b/core/crates/domain/src/lib.rs @@ -7,7 +7,9 @@ pub mod profile; pub mod relay; pub mod time; -pub use account::{AccountCreatedAt, AccountLabel, AccountSummary, KeyAvailability, SignerKind}; +pub use account::{ + AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, KeyAvailability, SignerKind, +}; pub use error::{SafeError, SafeErrorCode, SafeMessage}; pub use key::{Npub, Nsec, PublicKey, SecretKeyInput, SecretKeyInputKind}; pub use profile::{EventId, Kind0ProfileCandidate, ProfileMetadata, select_latest_kind0};