lib

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

commit 8ae6fb8e9aeb0aa4e5996ddd60ce031b2bd444b6
parent a79d958b1872a803418f80063a627e8080f8956b
Author: triesap <tyson@radroots.org>
Date:   Fri, 20 Mar 2026 17:20:34 +0000

nostr-accounts: harden keyring baseline and add readiness status

Diffstat:
MCargo.lock | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
MCargo.toml | 1+
Mcrates/nostr-accounts/Cargo.toml | 3++-
Mcrates/nostr-accounts/src/lib.rs | 2+-
Mcrates/nostr-accounts/src/manager.rs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/nostr-accounts/src/model.rs | 7+++++++
Mcrates/nostr-accounts/src/vault.rs | 30+++++++++++++++++++++++++++++-
7 files changed, 277 insertions(+), 11 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -547,6 +547,16 @@ dependencies = [ ] [[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -633,6 +643,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] +name = "dbus" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "dbus", + "openssl", + "zeroize", +] + +[[package]] name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -792,6 +824,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] name = "form_urlencoded" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1361,15 +1408,19 @@ dependencies = [ [[package]] name = "keyring" -version = "2.3.3" +version = "3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "363387f0019d714aa60cc30ab4fe501a747f4c08fc58f069dd14be971bd495a0" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" dependencies = [ "byteorder", - "lazy_static", + "dbus-secret-service", "linux-keyutils", - "security-framework", - "windows-sys 0.52.0", + "log", + "openssl", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", ] [[package]] @@ -1397,6 +1448,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] name = "libflate" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1741,6 +1802,54 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-src" +version = "300.5.5+3.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2124,6 +2233,7 @@ dependencies = [ "serde_json", "tempfile", "thiserror 1.0.69", + "zeroize", ] [[package]] @@ -2714,7 +2824,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4196,6 +4319,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml @@ -72,6 +72,7 @@ directories = { version = "6" } futures = { version = "0.3" } hex = { version = "0.4" } js-sys = { version = "0.3" } +keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "windows-native", "linux-native-sync-persistent", "vendored"] } nostr = { version = "0.44.2" } nostr-relay-pool = { version = "0.44.0" } nostr-sdk = { version = "0.44.1" } diff --git a/crates/nostr-accounts/Cargo.toml b/crates/nostr-accounts/Cargo.toml @@ -20,13 +20,14 @@ os-keyring = ["std", "dep:keyring"] ndb-bridge = ["std", "dep:radroots-nostr-ndb"] [dependencies] -keyring = { version = "2.3.3", optional = true, default-features = false, features = ["linux-no-secret-service", "platform-macos", "platform-ios", "platform-windows"] } +keyring = { workspace = true, optional = true } radroots-identity = { workspace = true, optional = true, default-features = false, features = ["std", "profile", "json-file"] } radroots-nostr-ndb = { workspace = true, optional = true, default-features = false, features = ["ndb", "giftwrap", "rt"] } radroots-runtime = { workspace = true, optional = true } serde = { workspace = true, optional = true, features = ["derive"] } serde_json = { workspace = true, optional = true } thiserror = { workspace = true } +zeroize = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/nostr-accounts/src/lib.rs b/crates/nostr-accounts/src/lib.rs @@ -20,7 +20,7 @@ pub mod prelude { #[cfg(feature = "std")] pub use crate::model::{ RADROOTS_NOSTR_ACCOUNTS_STORE_VERSION, RadrootsNostrAccountRecord, - RadrootsNostrAccountStoreState, + RadrootsNostrAccountStoreState, RadrootsNostrSelectedAccountStatus, }; #[cfg(feature = "ndb-bridge")] pub use crate::ndb_bridge::radroots_nostr_accounts_register_selected_secret_with_ndb; diff --git a/crates/nostr-accounts/src/manager.rs b/crates/nostr-accounts/src/manager.rs @@ -1,11 +1,14 @@ use crate::error::RadrootsNostrAccountsError; -use crate::model::{RadrootsNostrAccountRecord, RadrootsNostrAccountStoreState}; +use crate::model::{ + RadrootsNostrAccountRecord, RadrootsNostrAccountStoreState, RadrootsNostrSelectedAccountStatus, +}; use crate::store::{RadrootsNostrAccountStore, RadrootsNostrMemoryAccountStore}; use crate::vault::{RadrootsNostrSecretVault, RadrootsNostrSecretVaultMemory}; use radroots_identity::{RadrootsIdentity, RadrootsIdentityId, RadrootsIdentityPublic}; use std::path::Path; use std::sync::{Arc, RwLock}; use std::time::{SystemTime, UNIX_EPOCH}; +use zeroize::Zeroizing; #[derive(Clone)] pub struct RadrootsNostrAccountsManager { @@ -94,6 +97,26 @@ impl RadrootsNostrAccountsManager { .map(|record| record.public_identity.clone())) } + pub fn selected_account_status( + &self, + ) -> Result<RadrootsNostrSelectedAccountStatus, RadrootsNostrAccountsError> { + let Some(record) = self.selected_account()? else { + return Ok(RadrootsNostrSelectedAccountStatus::NotConfigured); + }; + + let Some(secret_key_hex) = self.vault.load_secret_hex(&record.account_id)? else { + return Ok(RadrootsNostrSelectedAccountStatus::PublicOnly { account: record }); + }; + + let secret_key_hex = Zeroizing::new(secret_key_hex); + let identity = RadrootsIdentity::from_secret_key_str(secret_key_hex.as_str())?; + if identity.public_key_hex() != record.public_identity.public_key_hex { + return Err(RadrootsNostrAccountsError::PublicKeyMismatch); + } + + Ok(RadrootsNostrSelectedAccountStatus::Ready { account: record }) + } + pub fn selected_signing_identity( &self, ) -> Result<Option<RadrootsIdentity>, RadrootsNostrAccountsError> { @@ -129,8 +152,9 @@ impl RadrootsNostrAccountsManager { make_selected: bool, ) -> Result<RadrootsIdentityId, RadrootsNostrAccountsError> { let account_id = identity.id(); + let secret_key_hex = Zeroizing::new(identity.secret_key_hex()); self.vault - .store_secret_hex(&account_id, identity.secret_key_hex().as_str())?; + .store_secret_hex(&account_id, secret_key_hex.as_str())?; let public_identity = identity.to_public(); self.upsert_public_identity(public_identity, label, make_selected) @@ -257,6 +281,7 @@ impl RadrootsNostrAccountsManager { let Some(secret_key_hex) = self.vault.load_secret_hex(&record.account_id)? else { return Ok(None); }; + let secret_key_hex = Zeroizing::new(secret_key_hex); let mut identity = RadrootsIdentity::from_secret_key_str(secret_key_hex.as_str())?; if identity.public_key_hex() != record.public_identity.public_key_hex { return Err(RadrootsNostrAccountsError::PublicKeyMismatch); @@ -524,6 +549,34 @@ mod tests { .expect("signing") .is_none() ); + match manager + .selected_account_status() + .expect("selected account status") + { + RadrootsNostrSelectedAccountStatus::PublicOnly { account } => { + assert_eq!(account.label.as_deref(), Some("watch")); + } + other => panic!("unexpected account status: {other:?}"), + } + } + + #[test] + fn selected_account_status_reports_ready_for_signing_identity() { + let manager = RadrootsNostrAccountsManager::new_in_memory(); + let selected_id = manager + .generate_identity(Some("primary".into()), true) + .expect("generate"); + + match manager + .selected_account_status() + .expect("selected account status") + { + RadrootsNostrSelectedAccountStatus::Ready { account } => { + assert_eq!(account.account_id, selected_id); + assert_eq!(account.label.as_deref(), Some("primary")); + } + other => panic!("unexpected account status: {other:?}"), + } } #[test] @@ -622,6 +675,12 @@ mod tests { .expect("selected signing") .is_none() ); + assert!(matches!( + manager + .selected_account_status() + .expect("selected account status"), + RadrootsNostrSelectedAccountStatus::NotConfigured + )); let missing_id = RadrootsIdentity::generate().id(); assert!( @@ -633,6 +692,39 @@ mod tests { } #[test] + fn selected_account_status_propagates_secret_integrity_errors() { + let manager = RadrootsNostrAccountsManager::new_in_memory(); + let account_id = manager + .generate_identity(Some("primary".into()), true) + .expect("generate"); + manager + .vault + .remove_secret(&account_id) + .expect("remove secret"); + + match manager + .selected_account_status() + .expect("selected account status") + { + RadrootsNostrSelectedAccountStatus::PublicOnly { account } => { + assert_eq!(account.account_id, account_id); + } + other => panic!("unexpected account status: {other:?}"), + } + + let wrong_identity = RadrootsIdentity::generate(); + manager + .vault + .store_secret_hex(&account_id, wrong_identity.secret_key_hex().as_str()) + .expect("store wrong secret"); + + let err = manager + .selected_account_status() + .expect_err("public key mismatch"); + assert_eq!(err.to_string(), "public key does not match secret key"); + } + + #[test] fn select_remove_export_and_lookup_paths() { let manager = RadrootsNostrAccountsManager::new_in_memory(); let first_id = manager diff --git a/crates/nostr-accounts/src/model.rs b/crates/nostr-accounts/src/model.rs @@ -20,6 +20,13 @@ pub struct RadrootsNostrAccountStoreState { pub accounts: Vec<RadrootsNostrAccountRecord>, } +#[derive(Debug, Clone)] +pub enum RadrootsNostrSelectedAccountStatus { + NotConfigured, + PublicOnly { account: RadrootsNostrAccountRecord }, + Ready { account: RadrootsNostrAccountRecord }, +} + impl Default for RadrootsNostrAccountStoreState { fn default() -> Self { Self { diff --git a/crates/nostr-accounts/src/vault.rs b/crates/nostr-accounts/src/vault.rs @@ -123,7 +123,7 @@ impl RadrootsNostrSecretVault for RadrootsNostrSecretVaultOsKeyring { ) -> Result<(), RadrootsNostrAccountsError> { let entry = keyring::Entry::new(self.service_name.as_str(), account_id.as_str()) .map_err(|source| RadrootsNostrAccountsError::Vault(source.to_string()))?; - match entry.delete_password() { + match entry.delete_credential() { Ok(_) | Err(keyring::Error::NoEntry) => Ok(()), Err(source) => Err(RadrootsNostrAccountsError::Vault(source.to_string())), } @@ -154,6 +154,34 @@ mod tests { } #[test] + fn memory_vault_distinguishes_present_and_missing_entries() { + let vault = RadrootsNostrSecretVaultMemory::new(); + let account_id = RadrootsIdentityId::parse( + "3bf0c63f0f4478a288f6b67f0429dbf7f5119d4fa7218a4c40ef1378f80f7606", + ) + .expect("account id"); + + assert!( + vault + .load_secret_hex(&account_id) + .expect("missing") + .is_none() + ); + + vault + .store_secret_hex(&account_id, "abc123") + .expect("store"); + + assert_eq!( + vault + .load_secret_hex(&account_id) + .expect("present") + .as_deref(), + Some("abc123") + ); + } + + #[test] fn memory_vault_reports_poisoned_lock() { let vault = RadrootsNostrSecretVaultMemory::new(); let account_id = RadrootsIdentityId::parse(