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:
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.