app

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

commit 74b23de1b68def7643eb1c93214d59c064c51166
parent 5990822112bae2624691fb90ec00454651c6bcd4
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:
Mcore/Cargo.lock | 18++++++++++++++++++
Mcore/Cargo.toml | 4++++
Mcore/crates/domain/Cargo.toml | 3+++
Mcore/crates/domain/src/key.rs | 109++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcore/crates/domain/src/lib.rs | 2+-
Mdocs/implementation/nostr-runtime-rcld.md | 2+-
6 files changed, 135 insertions(+), 3 deletions(-)

diff --git a/core/Cargo.lock b/core/Cargo.lock @@ -9,6 +9,9 @@ version = "0.1.0-alpha.0" [[package]] name = "radroots-studio-domain" version = "0.1.0-alpha.0" +dependencies = [ + "secrecy", +] [[package]] name = "radroots-studio-ffi" @@ -25,3 +28,18 @@ version = "0.1.0-alpha.0" [[package]] name = "radroots-studio-uniffi-bindgen" version = "0.1.0-alpha.0" + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" diff --git a/core/Cargo.toml b/core/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" diff --git a/core/crates/domain/Cargo.toml b/core/crates/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/core/crates/domain/src/key.rs b/core/crates/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/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; +pub use key::{PublicKey, SecretKeyInput, SecretKeyInputKind}; diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md @@ -458,7 +458,7 @@ handoff commit sequence. Gradle, and Makefile; do not add CI workflows or scripts. - [x] 04. Define domain module layout and safe error shell. - [x] 05. Implement Nostr public key value object. -- [ ] 06. Implement secret input boundary and redacted secret wrapper. +- [x] 06. Implement secret input boundary and redacted secret wrapper. - [ ] 07. Add NIP-19 public/secret display contract types. - [ ] 08. Implement relay URL parser and policy. - [ ] 09. Add account public metadata value types.