commit e082385ed7a2eb6bdd5e002fbfb6bee22407e58a
parent bc94d1d116ae048e68cae56d4d297c02e3691bfb
Author: triesap <tyson@radroots.org>
Date: Sun, 2 Aug 2026 18:50:15 +0000
core(secrets): add OS keyring credential adapter
- pin the maintained cross-platform keyring adapter
- bind credentials to the approved service and public identity
- map missing and unavailable platform outcomes to safe errors
- provide no plaintext or alternate persistence fallback
Diffstat:
4 files changed, 114 insertions(+), 0 deletions(-)
diff --git a/crates/studio_storage/Cargo.toml b/crates/studio_storage/Cargo.toml
@@ -9,6 +9,7 @@ repository.workspace = true
[dependencies]
radroots-studio-application = { path = "../application" }
radroots-studio-domain = { path = "../domain" }
+keyring.workspace = true
refinery.workspace = true
rusqlite.workspace = true
diff --git a/crates/studio_storage/src/lib.rs b/crates/studio_storage/src/lib.rs
@@ -5,7 +5,9 @@ pub mod accounts;
pub mod application_adapter;
pub mod db;
pub mod journal;
+pub mod os_keyring;
pub mod profiles;
pub use application_adapter::PersistentAppCore;
pub use db::Database;
+pub use os_keyring::OsKeyringSecretStore;
diff --git a/crates/studio_storage/src/os_keyring.rs b/crates/studio_storage/src/os_keyring.rs
@@ -0,0 +1,110 @@
+use keyring::{Entry, Error as KeyringError};
+use radroots_studio_application::SecretStore;
+use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput};
+
+pub const CREDENTIAL_SERVICE: &str = "org.radroots.studio.nostr";
+
+#[derive(Clone, Copy, Debug, Default)]
+pub struct OsKeyringSecretStore;
+
+impl OsKeyringSecretStore {
+ fn entry(public_key: PublicKey) -> Result<Entry, SafeError> {
+ Entry::new(CREDENTIAL_SERVICE, &public_key.to_hex()).map_err(|_| keyring_unavailable())
+ }
+}
+
+impl SecretStore for OsKeyringSecretStore {
+ fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError> {
+ let entry = Self::entry(public_key)?;
+ match entry.get_password() {
+ Ok(_) => return Err(credential_exists()),
+ Err(KeyringError::NoEntry) => {}
+ Err(_) => return Err(keyring_unavailable()),
+ }
+ secret
+ .with_exposed_secret(|value| entry.set_password(value))
+ .map_err(|_| keyring_unavailable())
+ }
+
+ fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError> {
+ let password = Self::entry(public_key)?
+ .get_password()
+ .map_err(|error| map_read_error(&error))?;
+ SecretKeyInput::parse(password)
+ }
+
+ fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError> {
+ match Self::entry(public_key)?.get_password() {
+ Ok(_) => Ok(true),
+ Err(KeyringError::NoEntry) => Ok(false),
+ Err(_) => Err(keyring_unavailable()),
+ }
+ }
+
+ fn delete(&self, public_key: PublicKey) -> Result<(), SafeError> {
+ Self::entry(public_key)?
+ .delete_credential()
+ .map_err(|error| map_read_error(&error))
+ }
+}
+
+const fn map_read_error(error: &KeyringError) -> SafeError {
+ match error {
+ KeyringError::NoEntry => credential_missing(),
+ _ => keyring_unavailable(),
+ }
+}
+
+const fn credential_exists() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::AccountAlreadyExists,
+ SafeMessage::new("The Nostr account credential already exists."),
+ )
+}
+
+const fn credential_missing() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::CredentialMissing,
+ SafeMessage::new("The Nostr account credential is missing."),
+ )
+}
+
+const fn keyring_unavailable() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::KeyringUnavailable,
+ SafeMessage::new("The operating system credential store is unavailable."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_studio_application::SecretStore;
+ use radroots_studio_domain::{PublicKey, SecretKeyInput};
+
+ use super::{CREDENTIAL_SERVICE, OsKeyringSecretStore};
+
+ #[test]
+ fn keyring_coordinates_are_stable_and_public() {
+ let public_key = PublicKey::from_bytes([0xab; 32]);
+ assert_eq!(CREDENTIAL_SERVICE, "org.radroots.studio.nostr");
+ assert_eq!(public_key.to_hex(), "ab".repeat(32));
+ }
+
+ #[test]
+ #[ignore = "mutates the current user's operating-system credential store"]
+ fn real_keyring_smoke_round_trips_and_deletes() {
+ let store = OsKeyringSecretStore;
+ let public_key = PublicKey::from_bytes([0xcd; 32]);
+ let _ = store.delete(public_key);
+ store
+ .put(
+ public_key,
+ SecretKeyInput::parse("11".repeat(32)).expect("secret"),
+ )
+ .expect("keyring put");
+ assert!(store.contains(public_key).expect("keyring contains"));
+ let loaded = store.load(public_key).expect("keyring load");
+ assert_eq!(loaded.with_exposed_secret(str::len), 64);
+ store.delete(public_key).expect("keyring delete");
+ }
+}
diff --git a/imports/studio_workspace/Cargo.toml b/imports/studio_workspace/Cargo.toml
@@ -24,6 +24,7 @@ all = "deny"
pedantic = "deny"
[workspace.dependencies]
+keyring = "=4.1.6"
refinery = { version = "=0.9.2", default-features = false, features = ["rusqlite"] }
rusqlite = { version = "=0.39.0", features = ["bundled"] }
secrecy = "=0.10.3"