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:
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};