commit cdd93d99ab1527565a013d01c6b2ce5570507648
parent fb14849faafd002aeb14ea3b19f84067a39b64e1
Author: triesap <tyson@radroots.org>
Date: Sun, 12 Apr 2026 18:03:32 +0000
secret_vault: remove shared plaintext backend
Diffstat:
6 files changed, 119 insertions(+), 65 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -2432,6 +2432,7 @@ dependencies = [
"radroots_identity",
"radroots_nostr_ndb",
"radroots_nostr_signer",
+ "radroots_protected_store",
"radroots_runtime",
"radroots_secret_vault",
"serde",
diff --git a/crates/nostr_accounts/Cargo.toml b/crates/nostr_accounts/Cargo.toml
@@ -19,6 +19,7 @@ std = [
"dep:serde_json",
"dep:radroots_identity",
"dep:radroots_nostr_signer",
+ "dep:radroots_protected_store",
"dep:radroots_runtime",
"dep:radroots_secret_vault",
]
@@ -39,6 +40,9 @@ radroots_nostr_ndb = { workspace = true, optional = true, default-features = fal
"giftwrap",
"rt",
] }
+radroots_protected_store = { workspace = true, optional = true, default-features = false, features = [
+ "std",
+] }
radroots_secret_vault = { workspace = true, optional = true, default-features = false, features = [
"std",
] }
diff --git a/crates/nostr_accounts/src/manager.rs b/crates/nostr_accounts/src/manager.rs
@@ -7,12 +7,19 @@ use crate::store::RadrootsNostrMemoryAccountStore;
use crate::store::{RadrootsNostrAccountStore, RadrootsNostrFileAccountStore};
#[cfg(feature = "memory-vault")]
use crate::vault::RadrootsNostrSecretVaultMemory;
+#[cfg(feature = "os-keyring")]
+use crate::vault::RadrootsNostrSecretVaultOsKeyring;
use crate::vault::{RadrootsSecretVault, account_secret_slot};
use radroots_identity::{RadrootsIdentity, RadrootsIdentityId, RadrootsIdentityPublic};
use radroots_nostr_signer::prelude::{
RadrootsNostrLocalSignerAvailability, RadrootsNostrLocalSignerCapability,
RadrootsNostrSignerCapability,
};
+use radroots_protected_store::RadrootsProtectedFileSecretVault;
+use radroots_secret_vault::{
+ RadrootsResolvedSecretBackend, RadrootsSecretBackend, RadrootsSecretBackendAvailability,
+ RadrootsSecretBackendSelection, RadrootsSecretVaultError,
+};
use std::path::Path;
use std::sync::{Arc, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -84,6 +91,31 @@ impl RadrootsNostrAccountsManager {
Self::new_file_backed(path, Arc::new(vault))
}
+ pub fn resolve_local_backend(
+ selection: RadrootsSecretBackendSelection,
+ availability: RadrootsSecretBackendAvailability,
+ ) -> Result<RadrootsResolvedSecretBackend, RadrootsSecretVaultError> {
+ selection.resolve(availability)
+ }
+
+ pub fn new_local_file_backed(
+ path: impl AsRef<Path>,
+ secrets_dir: impl AsRef<Path>,
+ selection: RadrootsSecretBackendSelection,
+ availability: RadrootsSecretBackendAvailability,
+ host_vault_service_name: impl Into<String>,
+ ) -> Result<(Self, RadrootsResolvedSecretBackend), RadrootsNostrAccountsError> {
+ let resolved = Self::resolve_local_backend(selection, availability)
+ .map_err(|error| RadrootsNostrAccountsError::Vault(error.to_string()))?;
+ let vault = local_file_backed_secret_vault(
+ resolved.backend,
+ secrets_dir.as_ref(),
+ host_vault_service_name.into(),
+ )?;
+ let manager = Self::new_file_backed(path, vault)?;
+ Ok((manager, resolved))
+ }
+
pub fn list_accounts(
&self,
) -> Result<Vec<RadrootsNostrAccountRecord>, RadrootsNostrAccountsError> {
@@ -416,6 +448,35 @@ impl RadrootsNostrAccountsManager {
}
}
+fn local_file_backed_secret_vault(
+ backend: RadrootsSecretBackend,
+ secrets_dir: &Path,
+ _host_vault_service_name: String,
+) -> Result<Arc<dyn RadrootsSecretVault>, RadrootsNostrAccountsError> {
+ match backend {
+ #[cfg(feature = "os-keyring")]
+ RadrootsSecretBackend::HostVault(_) => Ok(Arc::new(
+ RadrootsNostrSecretVaultOsKeyring::new(_host_vault_service_name),
+ )),
+ #[cfg(not(feature = "os-keyring"))]
+ RadrootsSecretBackend::HostVault(_) => Err(RadrootsNostrAccountsError::Vault(
+ "host_vault backend requires radroots_nostr_accounts os-keyring support".into(),
+ )),
+ RadrootsSecretBackend::EncryptedFile => {
+ Ok(Arc::new(RadrootsProtectedFileSecretVault::new(secrets_dir)))
+ }
+ #[cfg(feature = "memory-vault")]
+ RadrootsSecretBackend::Memory => Ok(Arc::new(RadrootsNostrSecretVaultMemory::new())),
+ #[cfg(not(feature = "memory-vault"))]
+ RadrootsSecretBackend::Memory => Err(RadrootsNostrAccountsError::Vault(
+ "memory backend requires radroots_nostr_accounts memory-vault support".into(),
+ )),
+ RadrootsSecretBackend::ExternalCommand => Err(RadrootsNostrAccountsError::Vault(
+ "external_command secret backend is not supported for local accounts".into(),
+ )),
+ }
+}
+
fn now_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -432,6 +493,10 @@ mod tests {
use crate::vault::RadrootsNostrSecretVaultMemory;
use crate::vault::RadrootsSecretVault;
use radroots_identity::RadrootsIdentityProfile;
+ use radroots_secret_vault::{
+ RadrootsHostVaultCapabilities, RadrootsSecretBackend, RadrootsSecretBackendAvailability,
+ RadrootsSecretBackendSelection,
+ };
use std::sync::Arc;
use std::sync::RwLock;
use std::thread;
@@ -687,6 +752,55 @@ mod tests {
}
#[test]
+ fn resolve_local_backend_applies_shared_fallback_policy() {
+ let resolved = RadrootsNostrAccountsManager::resolve_local_backend(
+ RadrootsSecretBackendSelection {
+ primary: RadrootsSecretBackend::HostVault(
+ radroots_secret_vault::RadrootsHostVaultPolicy::desktop(),
+ ),
+ fallback: Some(RadrootsSecretBackend::EncryptedFile),
+ },
+ RadrootsSecretBackendAvailability {
+ host_vault: RadrootsHostVaultCapabilities::unavailable(),
+ encrypted_file: true,
+ external_command: false,
+ memory: false,
+ },
+ )
+ .expect("fallback resolves");
+
+ assert_eq!(resolved.backend, RadrootsSecretBackend::EncryptedFile);
+ assert!(resolved.used_fallback);
+ }
+
+ #[test]
+ fn new_local_file_backed_rejects_external_command_backend() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let err = RadrootsNostrAccountsManager::new_local_file_backed(
+ temp.path().join("accounts.json"),
+ temp.path().join("secrets"),
+ RadrootsSecretBackendSelection {
+ primary: RadrootsSecretBackend::ExternalCommand,
+ fallback: None,
+ },
+ RadrootsSecretBackendAvailability {
+ host_vault: RadrootsHostVaultCapabilities::unavailable(),
+ encrypted_file: true,
+ external_command: true,
+ memory: false,
+ },
+ "org.radroots.test.local-account",
+ )
+ .err()
+ .expect("external command must be rejected");
+
+ assert_eq!(
+ err.to_string(),
+ "vault error: external_command secret backend is not supported for local accounts"
+ );
+ }
+
+ #[test]
fn watch_only_account_has_no_signing_identity() {
let temp = tempfile::tempdir().expect("tempdir");
let store = Arc::new(RadrootsNostrFileAccountStore::new(
diff --git a/crates/secret_vault/src/backend.rs b/crates/secret_vault/src/backend.rs
@@ -6,7 +6,6 @@ pub enum RadrootsSecretBackendKind {
EncryptedFile,
ExternalCommand,
Memory,
- PlaintextFile,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -15,7 +14,6 @@ pub enum RadrootsSecretBackend {
EncryptedFile,
ExternalCommand,
Memory,
- PlaintextFile,
}
impl RadrootsSecretBackend {
@@ -26,7 +24,6 @@ impl RadrootsSecretBackend {
Self::EncryptedFile => RadrootsSecretBackendKind::EncryptedFile,
Self::ExternalCommand => RadrootsSecretBackendKind::ExternalCommand,
Self::Memory => RadrootsSecretBackendKind::Memory,
- Self::PlaintextFile => RadrootsSecretBackendKind::PlaintextFile,
}
}
}
diff --git a/crates/secret_vault/src/error.rs b/crates/secret_vault/src/error.rs
@@ -80,7 +80,6 @@ impl fmt::Display for RadrootsSecretBackendKind {
Self::EncryptedFile => "encrypted_file",
Self::ExternalCommand => "external_command",
Self::Memory => "memory",
- Self::PlaintextFile => "plaintext_file",
};
f.write_str(value)
}
diff --git a/crates/secret_vault/src/selection.rs b/crates/secret_vault/src/selection.rs
@@ -14,7 +14,6 @@ pub struct RadrootsSecretBackendAvailability {
pub encrypted_file: bool,
pub external_command: bool,
pub memory: bool,
- pub plaintext_file: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -76,7 +75,6 @@ impl RadrootsSecretBackendAvailability {
RadrootsSecretBackend::EncryptedFile if self.encrypted_file => Ok(()),
RadrootsSecretBackend::ExternalCommand if self.external_command => Ok(()),
RadrootsSecretBackend::Memory if self.memory => Ok(()),
- RadrootsSecretBackend::PlaintextFile if self.plaintext_file => Ok(()),
_ => Err(RadrootsSecretVaultError::BackendUnavailable {
backend: backend.kind(),
}),
@@ -118,7 +116,6 @@ mod tests {
encrypted_file: true,
external_command: false,
memory: false,
- plaintext_file: false,
})
.expect("host vault resolves");
@@ -144,7 +141,6 @@ mod tests {
encrypted_file: true,
external_command: false,
memory: false,
- plaintext_file: false,
})
.expect("encrypted file fallback resolves");
@@ -170,7 +166,6 @@ mod tests {
encrypted_file: true,
external_command: false,
memory: false,
- plaintext_file: false,
})
.expect_err("missing fallback must fail");
@@ -199,7 +194,6 @@ mod tests {
encrypted_file: true,
external_command: false,
memory: false,
- plaintext_file: false,
})
.expect_err("unsupported host policy must fail");
@@ -212,32 +206,6 @@ mod tests {
}
#[test]
- fn encrypted_file_may_not_downgrade_to_plaintext_file() {
- let selection = RadrootsSecretBackendSelection {
- primary: RadrootsSecretBackend::EncryptedFile,
- fallback: Some(RadrootsSecretBackend::PlaintextFile),
- };
-
- let err = selection
- .resolve(RadrootsSecretBackendAvailability {
- host_vault: RadrootsHostVaultCapabilities::unavailable(),
- encrypted_file: false,
- external_command: false,
- memory: false,
- plaintext_file: true,
- })
- .expect_err("plaintext downgrade must fail");
-
- assert_eq!(
- err,
- RadrootsSecretVaultError::FallbackDisallowed {
- primary: RadrootsSecretBackendKind::EncryptedFile,
- fallback: RadrootsSecretBackendKind::PlaintextFile,
- }
- );
- }
-
- #[test]
fn external_command_may_not_downgrade_to_encrypted_file() {
let selection = RadrootsSecretBackendSelection {
primary: RadrootsSecretBackend::ExternalCommand,
@@ -250,7 +218,6 @@ mod tests {
encrypted_file: true,
external_command: false,
memory: false,
- plaintext_file: false,
})
.expect_err("external command downgrade must fail");
@@ -264,32 +231,6 @@ mod tests {
}
#[test]
- fn explicit_plaintext_file_selection_stays_explicit() {
- let selection = RadrootsSecretBackendSelection {
- primary: RadrootsSecretBackend::PlaintextFile,
- fallback: None,
- };
-
- let resolved = selection
- .resolve(RadrootsSecretBackendAvailability {
- host_vault: RadrootsHostVaultCapabilities::unavailable(),
- encrypted_file: false,
- external_command: false,
- memory: false,
- plaintext_file: true,
- })
- .expect("explicit plaintext file selection resolves");
-
- assert_eq!(
- resolved,
- RadrootsResolvedSecretBackend {
- backend: RadrootsSecretBackend::PlaintextFile,
- used_fallback: false,
- }
- );
- }
-
- #[test]
fn memory_backend_must_be_selected_explicitly() {
let selection = RadrootsSecretBackendSelection {
primary: RadrootsSecretBackend::Memory,
@@ -302,7 +243,6 @@ mod tests {
encrypted_file: false,
external_command: false,
memory: true,
- plaintext_file: false,
})
.expect("memory backend resolves");
@@ -328,7 +268,6 @@ mod tests {
encrypted_file: false,
external_command: false,
memory: false,
- plaintext_file: false,
})
.expect_err("unavailable fallback must fail");