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