lib

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

commit e6c36467b65e26b87420811caa37ae76d2fb8aed
parent 935fc38f166740a30258e2293339790ace85e993
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 23:13:08 +0000

security: harden direct credential custody

- remove debug formatting from secret-bearing domain wrappers
- serialize operating-system keyring access behind one adapter lock
- zeroize temporary credential reads and preserve typed failures
- keep credential storage free of fallback persistence paths

Diffstat:
Mcrates/studio_application/src/secrets.rs | 5+++--
Mcrates/studio_domain/src/key.rs | 31++++++-------------------------
Mcrates/studio_ffi/src/commands.rs | 2+-
Mcrates/studio_nostr/src/keys.rs | 3++-
Mcrates/studio_storage/Cargo.toml | 1+
Mcrates/studio_storage/src/os_keyring.rs | 31++++++++++++++++++++++++++-----
6 files changed, 39 insertions(+), 34 deletions(-)

diff --git a/crates/studio_application/src/secrets.rs b/crates/studio_application/src/secrets.rs @@ -222,7 +222,6 @@ mod tests { assert!(store.contains(public_key).expect("contains")); let loaded = store.load(public_key).expect("load"); assert_eq!(loaded.with_exposed_secret(str::len), 64); - assert!(!format!("{loaded:?}").contains(SECRET)); store.delete(public_key).expect("delete"); assert!(!store.contains(public_key).expect("contains")); } @@ -231,7 +230,9 @@ mod tests { fn secret_store_rejects_duplicates_and_reports_missing_credentials() { let store = InMemorySecretStore::default(); let public_key = PublicKey::from_bytes([2; 32]); - let missing = store.load(public_key).expect_err("missing"); + let Err(missing) = store.load(public_key) else { + panic!("missing credential was returned"); + }; assert_eq!(missing.code(), SafeErrorCode::CredentialMissing); store .put( diff --git a/crates/studio_domain/src/key.rs b/crates/studio_domain/src/key.rs @@ -94,12 +94,6 @@ impl Nsec { } } -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 { Nsec, @@ -150,16 +144,6 @@ impl SecretKeyInput { } } -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]); @@ -319,11 +303,7 @@ mod tests { 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)); + assert_eq!(input.with_exposed_secret(str::len), 64); } #[test] @@ -332,7 +312,7 @@ mod tests { let input = SecretKeyInput::parse(secret.clone()).expect("nsec-shaped input"); assert_eq!(input.kind(), SecretKeyInputKind::Nsec); - assert!(!format!("{input:?}").contains(&secret)); + assert_eq!(input.with_exposed_secret(str::len), secret.len()); } #[test] @@ -342,7 +322,9 @@ mod tests { "very-sensitive-input", &"GG".repeat(PUBLIC_KEY_BYTE_LENGTH), ] { - let error = SecretKeyInput::parse(value.to_owned()).expect_err("invalid secret"); + let Err(error) = SecretKeyInput::parse(value.to_owned()) else { + panic!("invalid secret accepted"); + }; assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey); if !value.is_empty() { assert!(!format!("{error:?}").contains(value)); @@ -362,8 +344,7 @@ mod tests { 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()); assert_eq!(nsec.with_exposed_secret(str::len), NSEC.len()); } diff --git a/crates/studio_ffi/src/commands.rs b/crates/studio_ffi/src/commands.rs @@ -271,7 +271,7 @@ impl StudioAppCore { let actor = RuntimeActorHandle::open( path, relays, - Arc::new(OsKeyringSecretStore), + Arc::new(OsKeyringSecretStore::default()), Arc::new(SystemClock), Arc::new(SdkNostrClient::new(Duration::from_secs(5))), NonZeroUsize::new(ACTOR_MAILBOX_CAPACITY).expect("nonzero actor mailbox capacity"), diff --git a/crates/studio_nostr/src/keys.rs b/crates/studio_nostr/src/keys.rs @@ -122,7 +122,8 @@ mod tests { assert!(npub.as_str().starts_with("npub1")); assert_eq!(secret.with_exposed_secret(str::len), 64); assert_eq!(nsec.with_exposed_secret(str::len), 63); - assert!(!format!("{secret:?} {nsec:?}").contains("nsec1")); + assert_eq!(secret.with_exposed_secret(str::len), 64); + assert_eq!(nsec.with_exposed_secret(str::len), 63); } #[test] diff --git a/crates/studio_storage/Cargo.toml b/crates/studio_storage/Cargo.toml @@ -11,6 +11,7 @@ radroots-studio-application = { path = "../application" } radroots-studio-domain = { path = "../domain" } fs2.workspace = true keyring.workspace = true +zeroize.workspace = true refinery.workspace = true rusqlite.workspace = true tokio.workspace = true diff --git a/crates/studio_storage/src/os_keyring.rs b/crates/studio_storage/src/os_keyring.rs @@ -1,23 +1,38 @@ +use std::sync::{Mutex, MutexGuard}; + use keyring::{Entry, Error as KeyringError}; use radroots_studio_application::SecretStore; use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput}; +use zeroize::Zeroizing; pub const CREDENTIAL_SERVICE: &str = "org.radroots.studio.nostr"; -#[derive(Clone, Copy, Debug, Default)] -pub struct OsKeyringSecretStore; +#[derive(Default)] +pub struct OsKeyringSecretStore { + operation_lock: Mutex<()>, +} impl OsKeyringSecretStore { fn entry(public_key: PublicKey) -> Result<Entry, SafeError> { Entry::new(CREDENTIAL_SERVICE, &public_key.to_hex()).map_err(|_| keyring_unavailable()) } + + fn operation(&self) -> MutexGuard<'_, ()> { + self.operation_lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } } impl SecretStore for OsKeyringSecretStore { fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError> { + let _operation = self.operation(); let entry = Self::entry(public_key)?; match entry.get_password() { - Ok(_) => return Err(credential_exists()), + Ok(password) => { + drop(Zeroizing::new(password)); + return Err(credential_exists()); + } Err(KeyringError::NoEntry) => {} Err(_) => return Err(keyring_unavailable()), } @@ -27,6 +42,7 @@ impl SecretStore for OsKeyringSecretStore { } fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError> { + let _operation = self.operation(); let password = Self::entry(public_key)? .get_password() .map_err(|error| map_read_error(&error))?; @@ -34,14 +50,19 @@ impl SecretStore for OsKeyringSecretStore { } fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError> { + let _operation = self.operation(); match Self::entry(public_key)?.get_password() { - Ok(_) => Ok(true), + Ok(password) => { + drop(Zeroizing::new(password)); + Ok(true) + } Err(KeyringError::NoEntry) => Ok(false), Err(_) => Err(keyring_unavailable()), } } fn delete(&self, public_key: PublicKey) -> Result<(), SafeError> { + let _operation = self.operation(); Self::entry(public_key)? .delete_credential() .map_err(|error| map_read_error(&error)) @@ -93,7 +114,7 @@ mod tests { #[test] #[ignore = "mutates the current user's operating-system credential store"] fn real_keyring_smoke_round_trips_and_deletes() { - let store = OsKeyringSecretStore; + let store = OsKeyringSecretStore::default(); let public_key = PublicKey::from_bytes([0xcd; 32]); let _ = store.delete(public_key); store