app

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

commit a10e42ada30a69a3712fefe922303c98e62afdb6
parent 74b23de1b68def7643eb1c93214d59c064c51166
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 18:05:32 +0000

core(domain): model NIP-19 display encodings

- separate human-facing npub from canonical public identity
- retain nsec inside a zeroizing redacted wrapper
- enforce NIP-19 key prefix, length, and character shape
- reserve checksum and key conversion for the Nostr adapter

Diffstat:
Mcore/crates/domain/src/key.rs | 108++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcore/crates/domain/src/lib.rs | 2+-
Mdocs/architecture/nostr-accounts.md | 4++++
Mdocs/implementation/nostr-runtime-rcld.md | 2+-
4 files changed, 111 insertions(+), 5 deletions(-)

diff --git a/core/crates/domain/src/key.rs b/core/crates/domain/src/key.rs @@ -9,6 +9,66 @@ 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; +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)) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +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()) + } +} + +impl fmt::Debug for Nsec { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("Nsec([REDACTED])") + } +} #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum SecretKeyInputKind { @@ -38,7 +98,7 @@ impl SecretKeyInput { .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) { SecretKeyInputKind::Hex - } else if value.starts_with("nsec1") && value.len() > "nsec1".len() { + } else if is_nip19_key_shape(&value, "nsec1") { SecretKeyInputKind::Nsec } else { return Err(invalid_secret_key()); @@ -168,14 +228,26 @@ const fn decode_hex_digit(byte: u8) -> Option<u8> { } } +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::{PUBLIC_KEY_BYTE_LENGTH, PublicKey, SecretKeyInput, SecretKeyInputKind}; + use super::{ + Npub, Nsec, PUBLIC_KEY_BYTE_LENGTH, PublicKey, SecretKeyInput, SecretKeyInputKind, + }; 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() { @@ -226,7 +298,7 @@ mod tests { #[test] fn secret_input_accepts_nsec_shape_without_exposing_it() { - let secret = "nsec1known-test-secret".to_owned(); + let secret = NSEC.to_owned(); let input = SecretKeyInput::parse(secret.clone()).expect("nsec-shaped input"); assert_eq!(input.kind(), SecretKeyInputKind::Nsec); @@ -247,4 +319,34 @@ mod tests { } } } + + #[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!(format!("{nsec:?}"), "Nsec([REDACTED])"); + assert!(!format!("{nsec:?}").contains(NSEC)); + 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/domain/src/lib.rs b/core/crates/domain/src/lib.rs @@ -8,4 +8,4 @@ pub mod relay; pub mod time; pub use error::{SafeError, SafeErrorCode, SafeMessage}; -pub use key::{PublicKey, SecretKeyInput, SecretKeyInputKind}; +pub use key::{Npub, Nsec, PublicKey, SecretKeyInput, SecretKeyInputKind}; diff --git a/docs/architecture/nostr-accounts.md b/docs/architecture/nostr-accounts.md @@ -18,6 +18,10 @@ encoding, and an optional local label is not identity. Saved, selected, and active are separate states. Startup restores saved accounts and selection but starts signed out. +Npub and nsec domain values enforce their expected NIP-19 shape. The Nostr +adapter performs checksum and key conversion validation. Nsec remains redacted +and is exposed only in the import adapter or one-time generated-key receipt. + Activation prepares a candidate signer/session before atomically replacing the working session. Sign out retains the saved account and credential. Confirmed removal deletes account-private state and credential, then chooses a diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md @@ -459,7 +459,7 @@ handoff commit sequence. - [x] 04. Define domain module layout and safe error shell. - [x] 05. Implement Nostr public key value object. - [x] 06. Implement secret input boundary and redacted secret wrapper. -- [ ] 07. Add NIP-19 public/secret display contract types. +- [x] 07. Add NIP-19 public/secret display contract types. - [ ] 08. Implement relay URL parser and policy. - [ ] 09. Add account public metadata value types. - [ ] 10. Add profile metadata model and kind-0 selection rules.