lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 933898ffb2d669bf95af7fdc38231abbaf31534e
parent 16ed6e5fa99f5a31f2b6257823a2235dced39162
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 18:04:03 +0000

core(domain): add redacted secret input boundary

- move import text into a zeroizing secrecy container
- distinguish lowercase secret hex from nsec-shaped input
- expose material only through a scoped adapter operation
- prove debug and error formatting never reveal known secrets

Diffstat:
Mcrates/studio_domain/Cargo.toml | 3+++
Mcrates/studio_domain/src/key.rs | 109++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/studio_domain/src/lib.rs | 2+-
Mimports/studio_workspace/Cargo.toml | 4++++
4 files changed, 116 insertions(+), 2 deletions(-)

diff --git a/crates/studio_domain/Cargo.toml b/crates/studio_domain/Cargo.toml @@ -6,5 +6,8 @@ rust-version.workspace = true license.workspace = true repository.workspace = true +[dependencies] +secrecy.workspace = true + [lints] workspace = true diff --git a/crates/studio_domain/src/key.rs b/crates/studio_domain/src/key.rs @@ -3,11 +3,73 @@ use std::fmt::{self, Display, Formatter}; use std::str::FromStr; +use secrecy::{ExposeSecret, SecretString}; + 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; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SecretKeyInputKind { + Nsec, + Hex, +} + +pub struct SecretKeyInput { + value: SecretString, + kind: SecretKeyInputKind, +} + +impl SecretKeyInput { + /// Moves one secret input string into a zeroizing boundary. + /// + /// Nsec inputs receive complete NIP-19 validation in the Nostr adapter. + /// Hex input is structurally validated here to prevent ambiguous fallback. + /// + /// # Errors + /// + /// Returns a safe invalid-secret-key error when the input is neither an + /// nsec-looking value nor exactly 64 lowercase hexadecimal characters. + pub fn parse(value: String) -> Result<Self, SafeError> { + let kind = if value.len() == PUBLIC_KEY_HEX_LENGTH + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + SecretKeyInputKind::Hex + } else if value.starts_with("nsec1") && value.len() > "nsec1".len() { + SecretKeyInputKind::Nsec + } else { + return Err(invalid_secret_key()); + }; + + Ok(Self { + value: SecretString::from(value), + kind, + }) + } + + #[must_use] + pub const fn kind(&self) -> SecretKeyInputKind { + self.kind + } + + pub fn with_exposed_secret<T>(&self, operation: impl FnOnce(&str) -> T) -> T { + operation(self.value.expose_secret()) + } +} + +impl fmt::Debug for SecretKeyInput { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SecretKeyInput") + .field("value", &"[REDACTED]") + .field("kind", &self.kind) + .finish() + } +} + #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct PublicKey([u8; PUBLIC_KEY_BYTE_LENGTH]); @@ -91,6 +153,13 @@ const fn invalid_public_key() -> SafeError { ) } +const fn invalid_secret_key() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidSecretKey, + SafeMessage::new("The Nostr secret key is invalid."), + ) +} + const fn decode_hex_digit(byte: u8) -> Option<u8> { match byte { b'0'..=b'9' => Some(byte - b'0'), @@ -103,7 +172,7 @@ const fn decode_hex_digit(byte: u8) -> Option<u8> { mod tests { use std::str::FromStr; - use super::{PUBLIC_KEY_BYTE_LENGTH, PublicKey}; + use super::{PUBLIC_KEY_BYTE_LENGTH, PublicKey, SecretKeyInput, SecretKeyInputKind}; use crate::SafeErrorCode; const HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; @@ -140,4 +209,42 @@ mod tests { assert!(low < high); } + + #[test] + fn secret_input_is_redacted_and_exposed_only_to_a_scoped_operation() { + let secret = "11".repeat(PUBLIC_KEY_BYTE_LENGTH); + let input = SecretKeyInput::parse(secret.clone()).expect("valid secret hex"); + + assert_eq!(input.kind(), SecretKeyInputKind::Hex); + assert_eq!(input.with_exposed_secret(str::len), secret.len()); + assert_eq!( + format!("{input:?}"), + "SecretKeyInput { value: \"[REDACTED]\", kind: Hex }" + ); + assert!(!format!("{input:?}").contains(&secret)); + } + + #[test] + fn secret_input_accepts_nsec_shape_without_exposing_it() { + let secret = "nsec1known-test-secret".to_owned(); + let input = SecretKeyInput::parse(secret.clone()).expect("nsec-shaped input"); + + assert_eq!(input.kind(), SecretKeyInputKind::Nsec); + assert!(!format!("{input:?}").contains(&secret)); + } + + #[test] + fn secret_input_rejects_invalid_hex_and_arbitrary_text() { + for value in [ + "", + "very-sensitive-input", + &"GG".repeat(PUBLIC_KEY_BYTE_LENGTH), + ] { + let error = SecretKeyInput::parse(value.to_owned()).expect_err("invalid secret"); + assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey); + if !value.is_empty() { + assert!(!format!("{error:?}").contains(value)); + } + } + } } 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; +pub use key::{PublicKey, SecretKeyInput, SecretKeyInputKind}; diff --git a/imports/studio_workspace/Cargo.toml b/imports/studio_workspace/Cargo.toml @@ -22,3 +22,7 @@ unsafe_code = "forbid" [workspace.lints.clippy] all = "deny" pedantic = "deny" + +[workspace.dependencies] +secrecy = "=0.10.3" +zeroize = "=1.9.0"