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