lib

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

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:
MCargo.lock | 5++++-
Mcrates/identity/src/identity.rs | 13+++++++++++++
Mcrates/identity/tests/identity.rs | 16++++++++++++++++
Mcrates/nostr-accounts/Cargo.toml | 11+++++++----
Mcrates/nostr-accounts/src/error.rs | 15+++++++++++++++
Mcrates/nostr-accounts/src/lib.rs | 4+++-
Mcrates/nostr-accounts/src/manager.rs | 172++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mcrates/nostr-accounts/src/vault.rs | 212+++----------------------------------------------------------------------------
Mcrates/secret-vault/Cargo.toml | 3+++
Mcrates/secret-vault/src/error.rs | 17+++++++++++++++++
Mcrates/secret-vault/src/lib.rs | 18++++++++++++++++++
Acrates/secret-vault/src/vault.rs | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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()); + } +}