commit 9ecc199f73ac13c32843acee241ec35ebc1d29eb
parent 933898ffb2d669bf95af7fdc38231abbaf31534e
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:
2 files changed, 106 insertions(+), 4 deletions(-)
diff --git a/crates/studio_domain/src/key.rs b/crates/studio_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/crates/studio_domain/src/lib.rs b/crates/studio_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};