commit 4e0b99b96800f255a0db42daef06c63a2062750e
parent 90317cf999db821a0fb6a719814ed6f68497de43
Author: triesap <tyson@radroots.org>
Date: Tue, 7 Apr 2026 17:36:56 +0000
nostr-accounts: adopt shared secret-vault boundary
Diffstat:
12 files changed, 327 insertions(+), 282 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -2436,11 +2436,11 @@ dependencies = [
name = "radroots-nostr-accounts"
version = "0.1.0-alpha.1"
dependencies = [
- "keyring",
"radroots-identity",
"radroots-nostr-ndb",
"radroots-nostr-signer",
"radroots-runtime",
+ "radroots-secret-vault",
"radroots-test-fixtures",
"serde",
"serde_json",
@@ -2613,6 +2613,9 @@ dependencies = [
[[package]]
name = "radroots-secret-vault"
version = "0.1.0-alpha.1"
+dependencies = [
+ "keyring",
+]
[[package]]
name = "radroots-simplex-agent-proto"
diff --git a/crates/identity/src/identity.rs b/crates/identity/src/identity.rs
@@ -264,6 +264,10 @@ impl RadrootsIdentity {
}
#[cfg(feature = "nip49")]
+ /// Export the current secret key as a NIP-49 `ncryptsec` payload.
+ ///
+ /// This is an explicit operator-facing import or export format, not the
+ /// canonical local file-storage contract for Radroots runtimes.
pub fn encrypt_secret_key_ncryptsec(&self, password: &str) -> Result<String, IdentityError> {
self.encrypt_secret_key_ncryptsec_with_options(
password,
@@ -272,6 +276,11 @@ impl RadrootsIdentity {
}
#[cfg(feature = "nip49")]
+ /// Export the current secret key as a NIP-49 `ncryptsec` payload with
+ /// explicit encryption options.
+ ///
+ /// This remains scoped to import or export behavior and must not become the
+ /// generic local secret-storage format.
pub fn encrypt_secret_key_ncryptsec_with_options(
&self,
password: &str,
@@ -397,6 +406,10 @@ impl RadrootsIdentity {
}
#[cfg(feature = "nip49")]
+ /// Import a secret key from a NIP-49 `ncryptsec` payload.
+ ///
+ /// This path is explicit by design so encrypted exports do not become an
+ /// ambient local file-storage format.
pub fn from_encrypted_secret_key_str(
secret_key: &str,
password: &str,
diff --git a/crates/identity/tests/identity.rs b/crates/identity/tests/identity.rs
@@ -330,6 +330,22 @@ fn encrypted_secret_key_rejects_invalid_and_wrong_password_inputs() {
));
}
+#[cfg(feature = "nip49")]
+#[test]
+fn load_from_path_auto_rejects_nip49_export_format() {
+ let identity = fixture_identity(FIXTURE_ALICE);
+ let encrypted = identity
+ .encrypt_secret_key_ncryptsec("fixture-password")
+ .unwrap();
+
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("identity.ncryptsec");
+ std::fs::write(&path, encrypted).unwrap();
+
+ let err = RadrootsIdentity::load_from_path_auto(&path).unwrap_err();
+ assert!(matches!(err, IdentityError::InvalidSecretKey(_)));
+}
+
#[test]
fn parse_failures_cover_public_key_errors() {
let err_empty = RadrootsIdentityId::parse(" ").unwrap_err();
diff --git a/crates/nostr-accounts/Cargo.toml b/crates/nostr-accounts/Cargo.toml
@@ -7,7 +7,7 @@ authors = [
]
rust-version.workspace = true
license.workspace = true
-description = "nostr protocol account primitives and vault interfaces for the radroots sdk"
+description = "nostr protocol account primitives and secret-vault integrations for the radroots sdk"
repository.workspace = true
homepage.workspace = true
documentation = "https://docs.rs/radroots-nostr-accounts"
@@ -21,14 +21,14 @@ std = [
"dep:radroots-identity",
"dep:radroots-nostr-signer",
"dep:radroots-runtime",
+ "dep:radroots-secret-vault",
]
file-store = ["std"]
-memory-vault = ["std"]
-os-keyring = ["std", "dep:keyring"]
+memory-vault = ["std", "radroots-secret-vault/memory-vault"]
+os-keyring = ["std", "radroots-secret-vault/os-keyring"]
ndb-bridge = ["std", "dep:radroots-nostr-ndb"]
[dependencies]
-keyring = { workspace = true, optional = true }
radroots-identity = { workspace = true, optional = true, default-features = false, features = [
"std",
"profile",
@@ -40,6 +40,9 @@ radroots-nostr-ndb = { workspace = true, optional = true, default-features = fal
"giftwrap",
"rt",
] }
+radroots-secret-vault = { workspace = true, optional = true, default-features = false, features = [
+ "std",
+] }
radroots-runtime = { workspace = true, optional = true }
serde = { workspace = true, optional = true, features = ["derive"] }
serde_json = { workspace = true, optional = true }
diff --git a/crates/nostr-accounts/src/error.rs b/crates/nostr-accounts/src/error.rs
@@ -38,11 +38,19 @@ impl From<radroots_runtime::RuntimeJsonError> for RadrootsNostrAccountsError {
}
}
+#[cfg(feature = "std")]
+impl From<radroots_secret_vault::RadrootsSecretVaultAccessError> for RadrootsNostrAccountsError {
+ fn from(value: radroots_secret_vault::RadrootsSecretVaultAccessError) -> Self {
+ Self::Vault(value.to_string())
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
use radroots_identity::IdentityError;
use radroots_runtime::RuntimeJsonError;
+ use radroots_secret_vault::RadrootsSecretVaultAccessError;
use std::path::PathBuf;
#[test]
@@ -58,4 +66,11 @@ mod tests {
let converted: RadrootsNostrAccountsError = source.into();
assert!(converted.to_string().starts_with("store error:"));
}
+
+ #[test]
+ fn converts_secret_vault_access_error() {
+ let source = RadrootsSecretVaultAccessError::Backend("vault failed".into());
+ let converted: RadrootsNostrAccountsError = source.into();
+ assert!(converted.to_string().starts_with("vault error:"));
+ }
}
diff --git a/crates/nostr-accounts/src/lib.rs b/crates/nostr-accounts/src/lib.rs
@@ -28,8 +28,10 @@ pub mod prelude {
pub use crate::store::{
RadrootsNostrAccountStore, RadrootsNostrFileAccountStore, RadrootsNostrMemoryAccountStore,
};
+ #[cfg(feature = "memory-vault")]
+ pub use crate::vault::RadrootsNostrSecretVaultMemory;
#[cfg(feature = "os-keyring")]
pub use crate::vault::RadrootsNostrSecretVaultOsKeyring;
#[cfg(feature = "std")]
- pub use crate::vault::{RadrootsNostrSecretVault, RadrootsNostrSecretVaultMemory};
+ pub use crate::vault::{RadrootsSecretVault, account_secret_slot};
}
diff --git a/crates/nostr-accounts/src/manager.rs b/crates/nostr-accounts/src/manager.rs
@@ -2,8 +2,12 @@ use crate::error::RadrootsNostrAccountsError;
use crate::model::{
RadrootsNostrAccountRecord, RadrootsNostrAccountStoreState, RadrootsNostrSelectedAccountStatus,
};
-use crate::store::{RadrootsNostrAccountStore, RadrootsNostrMemoryAccountStore};
-use crate::vault::{RadrootsNostrSecretVault, RadrootsNostrSecretVaultMemory};
+use crate::store::RadrootsNostrAccountStore;
+#[cfg(feature = "memory-vault")]
+use crate::store::RadrootsNostrMemoryAccountStore;
+#[cfg(feature = "memory-vault")]
+use crate::vault::RadrootsNostrSecretVaultMemory;
+use crate::vault::{RadrootsSecretVault, account_secret_slot};
use radroots_identity::{RadrootsIdentity, RadrootsIdentityId, RadrootsIdentityPublic};
use radroots_nostr_signer::prelude::{
RadrootsNostrLocalSignerAvailability, RadrootsNostrLocalSignerCapability,
@@ -17,11 +21,12 @@ use zeroize::Zeroizing;
#[derive(Clone)]
pub struct RadrootsNostrAccountsManager {
store: Arc<dyn RadrootsNostrAccountStore>,
- vault: Arc<dyn RadrootsNostrSecretVault>,
+ vault: Arc<dyn RadrootsSecretVault>,
state: Arc<RwLock<RadrootsNostrAccountStoreState>>,
}
impl RadrootsNostrAccountsManager {
+ #[cfg(feature = "memory-vault")]
pub fn new_in_memory() -> Self {
Self {
store: Arc::new(RadrootsNostrMemoryAccountStore::new()),
@@ -32,7 +37,7 @@ impl RadrootsNostrAccountsManager {
pub fn new(
store: Arc<dyn RadrootsNostrAccountStore>,
- vault: Arc<dyn RadrootsNostrSecretVault>,
+ vault: Arc<dyn RadrootsSecretVault>,
) -> Result<Self, RadrootsNostrAccountsError> {
let mut state = store.load()?;
if state.version != crate::model::RADROOTS_NOSTR_ACCOUNTS_STORE_VERSION {
@@ -194,8 +199,10 @@ impl RadrootsNostrAccountsManager {
) -> 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, secret_key_hex.as_str())?;
+ self.vault.store_secret(
+ account_secret_slot(&account_id).as_str(),
+ secret_key_hex.as_str(),
+ )?;
let public_identity = identity.to_public();
self.upsert_public_identity(public_identity, label, make_selected)
@@ -294,7 +301,8 @@ impl RadrootsNostrAccountsManager {
}
Ok(())
})?;
- self.vault.remove_secret(&account_id)?;
+ self.vault
+ .remove_secret(account_secret_slot(&account_id).as_str())?;
Ok(())
}
@@ -302,7 +310,9 @@ impl RadrootsNostrAccountsManager {
&self,
account_id: &RadrootsIdentityId,
) -> Result<Option<String>, RadrootsNostrAccountsError> {
- self.vault.load_secret_hex(account_id)
+ self.vault
+ .load_secret(account_secret_slot(account_id).as_str())
+ .map_err(Into::into)
}
pub fn migrate_legacy_identity_file(
@@ -319,7 +329,10 @@ impl RadrootsNostrAccountsManager {
&self,
record: RadrootsNostrAccountRecord,
) -> Result<Option<RadrootsIdentity>, RadrootsNostrAccountsError> {
- let Some(secret_key_hex) = self.vault.load_secret_hex(&record.account_id)? else {
+ let Some(secret_key_hex) = self
+ .vault
+ .load_secret(account_secret_slot(&record.account_id).as_str())?
+ else {
return Ok(None);
};
let secret_key_hex = Zeroizing::new(secret_key_hex);
@@ -351,7 +364,10 @@ impl RadrootsNostrAccountsManager {
&self,
record: &RadrootsNostrAccountRecord,
) -> Result<RadrootsNostrLocalSignerAvailability, RadrootsNostrAccountsError> {
- let Some(secret_key_hex) = self.vault.load_secret_hex(&record.account_id)? else {
+ let Some(secret_key_hex) = self
+ .vault
+ .load_secret(account_secret_slot(&record.account_id).as_str())?
+ else {
return Ok(RadrootsNostrLocalSignerAvailability::PublicOnly);
};
@@ -393,8 +409,8 @@ mod tests {
use crate::store::{
RadrootsNostrAccountStore, RadrootsNostrFileAccountStore, RadrootsNostrMemoryAccountStore,
};
- use crate::vault::RadrootsNostrSecretVault;
use crate::vault::RadrootsNostrSecretVaultMemory;
+ use crate::vault::RadrootsSecretVault;
use radroots_identity::RadrootsIdentityProfile;
use std::sync::Arc;
use std::sync::RwLock;
@@ -449,111 +465,117 @@ mod tests {
struct VaultStoreError;
- impl RadrootsNostrSecretVault for VaultStoreError {
- fn store_secret_hex(
+ impl RadrootsSecretVault for VaultStoreError {
+ fn store_secret(
&self,
- _account_id: &RadrootsIdentityId,
- _secret_key_hex: &str,
- ) -> Result<(), RadrootsNostrAccountsError> {
- Err(RadrootsNostrAccountsError::Vault(
- "vault store failed".into(),
- ))
+ _slot: &str,
+ _secret: &str,
+ ) -> Result<(), radroots_secret_vault::RadrootsSecretVaultAccessError> {
+ Err(
+ radroots_secret_vault::RadrootsSecretVaultAccessError::Backend(
+ "vault store failed".into(),
+ ),
+ )
}
- fn load_secret_hex(
+ fn load_secret(
&self,
- _account_id: &RadrootsIdentityId,
- ) -> Result<Option<String>, RadrootsNostrAccountsError> {
+ _slot: &str,
+ ) -> Result<Option<String>, radroots_secret_vault::RadrootsSecretVaultAccessError> {
Ok(None)
}
fn remove_secret(
&self,
- _account_id: &RadrootsIdentityId,
- ) -> Result<(), RadrootsNostrAccountsError> {
+ _slot: &str,
+ ) -> Result<(), radroots_secret_vault::RadrootsSecretVaultAccessError> {
Ok(())
}
}
struct VaultLoadError;
- impl RadrootsNostrSecretVault for VaultLoadError {
- fn store_secret_hex(
+ impl RadrootsSecretVault for VaultLoadError {
+ fn store_secret(
&self,
- _account_id: &RadrootsIdentityId,
- _secret_key_hex: &str,
- ) -> Result<(), RadrootsNostrAccountsError> {
+ _slot: &str,
+ _secret: &str,
+ ) -> Result<(), radroots_secret_vault::RadrootsSecretVaultAccessError> {
Ok(())
}
- fn load_secret_hex(
+ fn load_secret(
&self,
- _account_id: &RadrootsIdentityId,
- ) -> Result<Option<String>, RadrootsNostrAccountsError> {
- Err(RadrootsNostrAccountsError::Vault(
- "vault load failed".into(),
- ))
+ _slot: &str,
+ ) -> Result<Option<String>, radroots_secret_vault::RadrootsSecretVaultAccessError> {
+ Err(
+ radroots_secret_vault::RadrootsSecretVaultAccessError::Backend(
+ "vault load failed".into(),
+ ),
+ )
}
fn remove_secret(
&self,
- _account_id: &RadrootsIdentityId,
- ) -> Result<(), RadrootsNostrAccountsError> {
+ _slot: &str,
+ ) -> Result<(), radroots_secret_vault::RadrootsSecretVaultAccessError> {
Ok(())
}
}
struct VaultInvalidSecret;
- impl RadrootsNostrSecretVault for VaultInvalidSecret {
- fn store_secret_hex(
+ impl RadrootsSecretVault for VaultInvalidSecret {
+ fn store_secret(
&self,
- _account_id: &RadrootsIdentityId,
- _secret_key_hex: &str,
- ) -> Result<(), RadrootsNostrAccountsError> {
+ _slot: &str,
+ _secret: &str,
+ ) -> Result<(), radroots_secret_vault::RadrootsSecretVaultAccessError> {
Ok(())
}
- fn load_secret_hex(
+ fn load_secret(
&self,
- _account_id: &RadrootsIdentityId,
- ) -> Result<Option<String>, RadrootsNostrAccountsError> {
+ _slot: &str,
+ ) -> Result<Option<String>, radroots_secret_vault::RadrootsSecretVaultAccessError> {
Ok(Some("invalid-secret".to_string()))
}
fn remove_secret(
&self,
- _account_id: &RadrootsIdentityId,
- ) -> Result<(), RadrootsNostrAccountsError> {
+ _slot: &str,
+ ) -> Result<(), radroots_secret_vault::RadrootsSecretVaultAccessError> {
Ok(())
}
}
struct VaultRemoveError;
- impl RadrootsNostrSecretVault for VaultRemoveError {
- fn store_secret_hex(
+ impl RadrootsSecretVault for VaultRemoveError {
+ fn store_secret(
&self,
- _account_id: &RadrootsIdentityId,
- _secret_key_hex: &str,
- ) -> Result<(), RadrootsNostrAccountsError> {
+ _slot: &str,
+ _secret: &str,
+ ) -> Result<(), radroots_secret_vault::RadrootsSecretVaultAccessError> {
Ok(())
}
- fn load_secret_hex(
+ fn load_secret(
&self,
- _account_id: &RadrootsIdentityId,
- ) -> Result<Option<String>, RadrootsNostrAccountsError> {
+ _slot: &str,
+ ) -> Result<Option<String>, radroots_secret_vault::RadrootsSecretVaultAccessError> {
Ok(None)
}
fn remove_secret(
&self,
- _account_id: &RadrootsIdentityId,
- ) -> Result<(), RadrootsNostrAccountsError> {
- Err(RadrootsNostrAccountsError::Vault(
- "vault remove failed".into(),
- ))
+ _slot: &str,
+ ) -> Result<(), radroots_secret_vault::RadrootsSecretVaultAccessError> {
+ Err(
+ radroots_secret_vault::RadrootsSecretVaultAccessError::Backend(
+ "vault remove failed".into(),
+ ),
+ )
}
}
@@ -801,7 +823,7 @@ mod tests {
.expect("generate");
manager
.vault
- .remove_secret(&account_id)
+ .remove_secret(account_secret_slot(&account_id).as_str())
.expect("remove secret");
let status = manager
@@ -814,7 +836,10 @@ mod tests {
let wrong_identity = RadrootsIdentity::generate();
manager
.vault
- .store_secret_hex(&account_id, wrong_identity.secret_key_hex().as_str())
+ .store_secret(
+ account_secret_slot(&account_id).as_str(),
+ wrong_identity.secret_key_hex().as_str(),
+ )
.expect("store wrong secret");
let err = manager
@@ -1000,11 +1025,11 @@ mod tests {
let public = RadrootsIdentity::generate().to_public();
let account_id = public.id.clone();
vault
- .store_secret_hex(&account_id, "secret")
+ .store_secret(account_secret_slot(&account_id).as_str(), "secret")
.expect("vault store");
assert!(
vault
- .load_secret_hex(&account_id)
+ .load_secret(account_secret_slot(&account_id).as_str())
.expect("vault load")
.is_none()
);
@@ -1032,7 +1057,10 @@ mod tests {
let wrong_identity = RadrootsIdentity::generate();
vault
- .store_secret_hex(&mismatch_id, wrong_identity.secret_key_hex().as_str())
+ .store_secret(
+ account_secret_slot(&mismatch_id).as_str(),
+ wrong_identity.secret_key_hex().as_str(),
+ )
.expect("vault store");
let mismatch = manager
@@ -1254,26 +1282,28 @@ mod tests {
let vault_store_error = VaultStoreError;
assert!(
vault_store_error
- .load_secret_hex(&account_id)
+ .load_secret(account_secret_slot(&account_id).as_str())
.expect("load")
.is_none()
);
vault_store_error
- .remove_secret(&account_id)
+ .remove_secret(account_secret_slot(&account_id).as_str())
.expect("remove");
let vault_load_error = VaultLoadError;
vault_load_error
- .store_secret_hex(&account_id, "secret")
+ .store_secret(account_secret_slot(&account_id).as_str(), "secret")
.expect("store");
- vault_load_error.remove_secret(&account_id).expect("remove");
+ vault_load_error
+ .remove_secret(account_secret_slot(&account_id).as_str())
+ .expect("remove");
let vault_invalid_secret = VaultInvalidSecret;
vault_invalid_secret
- .store_secret_hex(&account_id, "secret")
+ .store_secret(account_secret_slot(&account_id).as_str(), "secret")
.expect("store");
vault_invalid_secret
- .remove_secret(&account_id)
+ .remove_secret(account_secret_slot(&account_id).as_str())
.expect("remove");
}
}
diff --git a/crates/nostr-accounts/src/vault.rs b/crates/nostr-accounts/src/vault.rs
@@ -1,210 +1,12 @@
-use crate::error::RadrootsNostrAccountsError;
use radroots_identity::RadrootsIdentityId;
-use std::collections::HashMap;
-use std::sync::{Arc, RwLock};
-
-pub trait RadrootsNostrSecretVault: Send + Sync {
- fn store_secret_hex(
- &self,
- account_id: &RadrootsIdentityId,
- secret_key_hex: &str,
- ) -> Result<(), RadrootsNostrAccountsError>;
- fn load_secret_hex(
- &self,
- account_id: &RadrootsIdentityId,
- ) -> Result<Option<String>, RadrootsNostrAccountsError>;
- fn remove_secret(
- &self,
- account_id: &RadrootsIdentityId,
- ) -> Result<(), RadrootsNostrAccountsError>;
-}
-
-#[derive(Debug, Clone, Default)]
-pub struct RadrootsNostrSecretVaultMemory {
- entries: Arc<RwLock<HashMap<String, String>>>,
-}
-
-impl RadrootsNostrSecretVaultMemory {
- pub fn new() -> Self {
- Self::default()
- }
-}
-
-impl RadrootsNostrSecretVault for RadrootsNostrSecretVaultMemory {
- fn store_secret_hex(
- &self,
- account_id: &RadrootsIdentityId,
- secret_key_hex: &str,
- ) -> Result<(), RadrootsNostrAccountsError> {
- let mut guard = self
- .entries
- .write()
- .map_err(|_| RadrootsNostrAccountsError::Vault("memory vault poisoned".into()))?;
- guard.insert(account_id.to_string(), secret_key_hex.to_owned());
- Ok(())
- }
-
- fn load_secret_hex(
- &self,
- account_id: &RadrootsIdentityId,
- ) -> Result<Option<String>, RadrootsNostrAccountsError> {
- let guard = self
- .entries
- .read()
- .map_err(|_| RadrootsNostrAccountsError::Vault("memory vault poisoned".into()))?;
- Ok(guard.get(account_id.as_str()).cloned())
- }
-
- fn remove_secret(
- &self,
- account_id: &RadrootsIdentityId,
- ) -> Result<(), RadrootsNostrAccountsError> {
- let mut guard = self
- .entries
- .write()
- .map_err(|_| RadrootsNostrAccountsError::Vault("memory vault poisoned".into()))?;
- guard.remove(account_id.as_str());
- Ok(())
- }
-}
-
-#[cfg(feature = "os-keyring")]
-#[derive(Debug, Clone)]
-pub struct RadrootsNostrSecretVaultOsKeyring {
- service_name: String,
-}
+#[cfg(feature = "memory-vault")]
+pub use radroots_secret_vault::RadrootsSecretVaultMemory as RadrootsNostrSecretVaultMemory;
#[cfg(feature = "os-keyring")]
-impl RadrootsNostrSecretVaultOsKeyring {
- pub fn new(service_name: impl Into<String>) -> Self {
- Self {
- service_name: service_name.into(),
- }
- }
-}
-
-#[cfg(feature = "os-keyring")]
-impl Default for RadrootsNostrSecretVaultOsKeyring {
- fn default() -> Self {
- Self::new("org.radroots.nostr.accounts")
- }
-}
-
-#[cfg(feature = "os-keyring")]
-impl RadrootsNostrSecretVault for RadrootsNostrSecretVaultOsKeyring {
- fn store_secret_hex(
- &self,
- account_id: &RadrootsIdentityId,
- secret_key_hex: &str,
- ) -> Result<(), RadrootsNostrAccountsError> {
- let entry = keyring::Entry::new(self.service_name.as_str(), account_id.as_str())
- .map_err(|source| RadrootsNostrAccountsError::Vault(source.to_string()))?;
- entry
- .set_password(secret_key_hex)
- .map_err(|source| RadrootsNostrAccountsError::Vault(source.to_string()))
- }
-
- fn load_secret_hex(
- &self,
- account_id: &RadrootsIdentityId,
- ) -> Result<Option<String>, 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.get_password() {
- Ok(secret) => Ok(Some(secret)),
- Err(keyring::Error::NoEntry) => Ok(None),
- Err(source) => Err(RadrootsNostrAccountsError::Vault(source.to_string())),
- }
- }
-
- fn remove_secret(
- &self,
- account_id: &RadrootsIdentityId,
- ) -> 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_credential() {
- Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
- Err(source) => Err(RadrootsNostrAccountsError::Vault(source.to_string())),
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use radroots_identity::RadrootsIdentityId;
- use radroots_test_fixtures::FIXTURE_ALICE;
- use std::thread;
-
- fn fixture_account_id() -> RadrootsIdentityId {
- RadrootsIdentityId::parse(FIXTURE_ALICE.public_key_hex).expect("account id")
- }
-
- #[test]
- fn memory_vault_round_trip() {
- let vault = RadrootsNostrSecretVaultMemory::new();
- let account_id = fixture_account_id();
- vault
- .store_secret_hex(&account_id, "abc123")
- .expect("store");
- let loaded = vault.load_secret_hex(&account_id).expect("load");
- assert_eq!(loaded.as_deref(), Some("abc123"));
- vault.remove_secret(&account_id).expect("remove");
- let loaded = vault.load_secret_hex(&account_id).expect("load");
- assert!(loaded.is_none());
- }
-
- #[test]
- fn memory_vault_distinguishes_present_and_missing_entries() {
- let vault = RadrootsNostrSecretVaultMemory::new();
- let account_id = fixture_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 = fixture_account_id();
-
- let shared = vault.entries.clone();
- let _ = thread::spawn(move || {
- let _guard = shared.write().expect("write");
- panic!("poison memory vault");
- })
- .join();
-
- let store = vault
- .store_secret_hex(&account_id, "abc123")
- .expect_err("poisoned store");
- assert!(store.to_string().starts_with("vault error:"));
-
- let load = vault
- .load_secret_hex(&account_id)
- .expect_err("poisoned load");
- assert!(load.to_string().starts_with("vault error:"));
+pub use radroots_secret_vault::RadrootsSecretVaultOsKeyring as RadrootsNostrSecretVaultOsKeyring;
+pub use radroots_secret_vault::{RadrootsSecretVault, RadrootsSecretVaultAccessError};
- let remove = vault
- .remove_secret(&account_id)
- .expect_err("poisoned remove");
- assert!(remove.to_string().starts_with("vault error:"));
- }
+#[must_use]
+pub fn account_secret_slot(account_id: &RadrootsIdentityId) -> String {
+ account_id.to_string()
}
diff --git a/crates/secret-vault/Cargo.toml b/crates/secret-vault/Cargo.toml
@@ -16,5 +16,8 @@ readme = "README.md"
[features]
default = []
std = []
+memory-vault = ["std"]
+os-keyring = ["std", "dep:keyring"]
[dependencies]
+keyring = { workspace = true, optional = true }
diff --git a/crates/secret-vault/src/error.rs b/crates/secret-vault/src/error.rs
@@ -1,4 +1,5 @@
use crate::backend::RadrootsSecretBackendKind;
+use alloc::string::String;
use core::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -26,6 +27,11 @@ pub enum RadrootsSecretVaultError {
},
}
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RadrootsSecretVaultAccessError {
+ Backend(String),
+}
+
impl fmt::Display for RadrootsHostVaultRequirement {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let value = match self {
@@ -37,6 +43,14 @@ impl fmt::Display for RadrootsHostVaultRequirement {
}
}
+impl fmt::Display for RadrootsSecretVaultAccessError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Backend(message) => write!(f, "secret vault access error: {message}"),
+ }
+ }
+}
+
impl fmt::Display for RadrootsSecretVaultError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@@ -74,3 +88,6 @@ impl fmt::Display for RadrootsSecretBackendKind {
#[cfg(feature = "std")]
impl std::error::Error for RadrootsSecretVaultError {}
+
+#[cfg(feature = "std")]
+impl std::error::Error for RadrootsSecretVaultAccessError {}
diff --git a/crates/secret-vault/src/lib.rs b/crates/secret-vault/src/lib.rs
@@ -9,10 +9,14 @@ pub mod backend;
pub mod error;
pub mod policy;
pub mod selection;
+#[cfg(feature = "std")]
+pub mod vault;
pub mod wrap;
pub mod prelude {
pub use crate::backend::{RadrootsSecretBackend, RadrootsSecretBackendKind};
+ #[cfg(feature = "std")]
+ pub use crate::error::RadrootsSecretVaultAccessError;
pub use crate::error::{RadrootsHostVaultRequirement, RadrootsSecretVaultError};
pub use crate::policy::{
RadrootsHostVaultCapabilities, RadrootsHostVaultHardwarePolicy, RadrootsHostVaultPolicy,
@@ -22,10 +26,18 @@ pub mod prelude {
RadrootsResolvedSecretBackend, RadrootsSecretBackendAvailability,
RadrootsSecretBackendSelection,
};
+ #[cfg(feature = "std")]
+ pub use crate::vault::RadrootsSecretVault;
+ #[cfg(feature = "memory-vault")]
+ pub use crate::vault::RadrootsSecretVaultMemory;
+ #[cfg(feature = "os-keyring")]
+ pub use crate::vault::RadrootsSecretVaultOsKeyring;
pub use crate::wrap::RadrootsSecretKeyWrapping;
}
pub use backend::{RadrootsSecretBackend, RadrootsSecretBackendKind};
+#[cfg(feature = "std")]
+pub use error::RadrootsSecretVaultAccessError;
pub use error::{RadrootsHostVaultRequirement, RadrootsSecretVaultError};
pub use policy::{
RadrootsHostVaultCapabilities, RadrootsHostVaultHardwarePolicy, RadrootsHostVaultPolicy,
@@ -35,4 +47,10 @@ pub use selection::{
RadrootsResolvedSecretBackend, RadrootsSecretBackendAvailability,
RadrootsSecretBackendSelection,
};
+#[cfg(feature = "std")]
+pub use vault::RadrootsSecretVault;
+#[cfg(feature = "memory-vault")]
+pub use vault::RadrootsSecretVaultMemory;
+#[cfg(feature = "os-keyring")]
+pub use vault::RadrootsSecretVaultOsKeyring;
pub use wrap::RadrootsSecretKeyWrapping;
diff --git a/crates/secret-vault/src/vault.rs b/crates/secret-vault/src/vault.rs
@@ -0,0 +1,123 @@
+use alloc::string::{String, ToString};
+
+use crate::error::RadrootsSecretVaultAccessError;
+
+pub trait RadrootsSecretVault: Send + Sync {
+ fn store_secret(&self, slot: &str, secret: &str) -> Result<(), RadrootsSecretVaultAccessError>;
+
+ fn load_secret(&self, slot: &str) -> Result<Option<String>, RadrootsSecretVaultAccessError>;
+
+ fn remove_secret(&self, slot: &str) -> Result<(), RadrootsSecretVaultAccessError>;
+}
+
+#[cfg(feature = "memory-vault")]
+#[derive(Debug, Clone, Default)]
+pub struct RadrootsSecretVaultMemory {
+ entries: std::sync::Arc<std::sync::RwLock<std::collections::HashMap<String, String>>>,
+}
+
+#[cfg(feature = "memory-vault")]
+impl RadrootsSecretVaultMemory {
+ #[must_use]
+ pub fn new() -> Self {
+ Self::default()
+ }
+}
+
+#[cfg(feature = "memory-vault")]
+impl RadrootsSecretVault for RadrootsSecretVaultMemory {
+ fn store_secret(&self, slot: &str, secret: &str) -> Result<(), RadrootsSecretVaultAccessError> {
+ let mut guard = self
+ .entries
+ .write()
+ .map_err(|_| RadrootsSecretVaultAccessError::Backend("memory vault poisoned".into()))?;
+ guard.insert(slot.to_string(), secret.to_string());
+ Ok(())
+ }
+
+ fn load_secret(&self, slot: &str) -> Result<Option<String>, RadrootsSecretVaultAccessError> {
+ let guard = self
+ .entries
+ .read()
+ .map_err(|_| RadrootsSecretVaultAccessError::Backend("memory vault poisoned".into()))?;
+ Ok(guard.get(slot).cloned())
+ }
+
+ fn remove_secret(&self, slot: &str) -> Result<(), RadrootsSecretVaultAccessError> {
+ let mut guard = self
+ .entries
+ .write()
+ .map_err(|_| RadrootsSecretVaultAccessError::Backend("memory vault poisoned".into()))?;
+ guard.remove(slot);
+ Ok(())
+ }
+}
+
+#[cfg(feature = "os-keyring")]
+#[derive(Debug, Clone)]
+pub struct RadrootsSecretVaultOsKeyring {
+ service_name: String,
+}
+
+#[cfg(feature = "os-keyring")]
+impl RadrootsSecretVaultOsKeyring {
+ #[must_use]
+ pub fn new(service_name: impl Into<String>) -> Self {
+ Self {
+ service_name: service_name.into(),
+ }
+ }
+}
+
+#[cfg(feature = "os-keyring")]
+impl Default for RadrootsSecretVaultOsKeyring {
+ fn default() -> Self {
+ Self::new("org.radroots.secret-vault")
+ }
+}
+
+#[cfg(feature = "os-keyring")]
+impl RadrootsSecretVault for RadrootsSecretVaultOsKeyring {
+ fn store_secret(&self, slot: &str, secret: &str) -> Result<(), RadrootsSecretVaultAccessError> {
+ let entry = keyring::Entry::new(self.service_name.as_str(), slot)
+ .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string()))?;
+ entry
+ .set_password(secret)
+ .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string()))
+ }
+
+ fn load_secret(&self, slot: &str) -> Result<Option<String>, RadrootsSecretVaultAccessError> {
+ let entry = keyring::Entry::new(self.service_name.as_str(), slot)
+ .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string()))?;
+ match entry.get_password() {
+ Ok(secret) => Ok(Some(secret)),
+ Err(keyring::Error::NoEntry) => Ok(None),
+ Err(source) => Err(RadrootsSecretVaultAccessError::Backend(source.to_string())),
+ }
+ }
+
+ fn remove_secret(&self, slot: &str) -> Result<(), RadrootsSecretVaultAccessError> {
+ let entry = keyring::Entry::new(self.service_name.as_str(), slot)
+ .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string()))?;
+ match entry.delete_credential() {
+ Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
+ Err(source) => Err(RadrootsSecretVaultAccessError::Backend(source.to_string())),
+ }
+ }
+}
+
+#[cfg(all(test, feature = "memory-vault"))]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn memory_vault_round_trip() {
+ let vault = RadrootsSecretVaultMemory::new();
+ vault.store_secret("alice", "abc123").expect("store");
+ let loaded = vault.load_secret("alice").expect("load");
+ assert_eq!(loaded.as_deref(), Some("abc123"));
+ vault.remove_secret("alice").expect("remove");
+ let loaded = vault.load_secret("alice").expect("load");
+ assert!(loaded.is_none());
+ }
+}