commit 48f1ff498c0a2d5a0b65156929c584e9cf333414
parent 1003930ba089a2cad9c742cc987eed8d3b219675
Author: kernelkind <kernelkind@gmail.com>
Date: Thu, 6 Nov 2025 19:04:00 -0500
feat(key-store): upgrade key storage to use OS secure store
Signed-off-by: kernelkind <kernelkind@gmail.com>
Diffstat:
2 files changed, 83 insertions(+), 19 deletions(-)
diff --git a/crates/notedeck/src/storage/account_storage.rs b/crates/notedeck/src/storage/account_storage.rs
@@ -2,22 +2,39 @@ use crate::{user_account::UserAccountSerializable, Result};
use enostr::{Keypair, Pubkey, SerializableKeypair};
use tokenator::{TokenParser, TokenSerializable, TokenWriter};
-use super::file_storage::{delete_file, write_file, Directory};
+use super::{
+ file_storage::{delete_file, write_file, Directory},
+ keyring_store::KeyringStore,
+};
static SELECTED_PUBKEY_FILE_NAME: &str = "selected_pubkey";
-/// An OS agnostic file key storage implementation
-#[derive(Debug, PartialEq, Clone)]
+/// An OS agnostic key storage implementation backed by the operating system's secure store.
+#[derive(Debug, Clone)]
pub struct AccountStorage {
accounts_directory: Directory,
selected_key_directory: Directory,
+ keyring: KeyringStore,
}
impl AccountStorage {
pub fn new(accounts_directory: Directory, selected_key_directory: Directory) -> Self {
+ Self::with_keyring(
+ accounts_directory,
+ selected_key_directory,
+ KeyringStore::default(),
+ )
+ }
+
+ pub(crate) fn with_keyring(
+ accounts_directory: Directory,
+ selected_key_directory: Directory,
+ keyring: KeyringStore,
+ ) -> Self {
Self {
accounts_directory,
selected_key_directory,
+ keyring,
}
}
@@ -27,6 +44,28 @@ impl AccountStorage {
AccountStorageWriter::new(self),
)
}
+
+ fn persist_account(&self, account: &UserAccountSerializable) -> Result<()> {
+ if let Some(secret) = account.key.secret_key.as_ref() {
+ self.keyring.store_secret(&account.key.pubkey, secret)?;
+ } else {
+ // if the account is npub only, make sure the db doesn't somehow have the nsec
+ self.keyring.remove_secret(&account.key.pubkey)?;
+ }
+
+ self.write_account_without_secret(account)
+ }
+
+ fn write_account_without_secret(&self, account: &UserAccountSerializable) -> Result<()> {
+ let mut writer = TokenWriter::new("\t");
+ sanitized_account(account).serialize_tokens(&mut writer);
+
+ write_file(
+ &self.accounts_directory.file_path,
+ account.key.pubkey.hex(),
+ writer.str(),
+ )
+ }
}
pub struct AccountStorageWriter {
@@ -39,17 +78,13 @@ impl AccountStorageWriter {
}
pub fn write_account(&self, account: &UserAccountSerializable) -> Result<()> {
- let mut writer = TokenWriter::new("\t");
- account.serialize_tokens(&mut writer);
- write_file(
- &self.storage.accounts_directory.file_path,
- account.key.pubkey.hex(),
- writer.str(),
- )
+ self.storage.persist_account(account)
}
pub fn remove_key(&self, key: &Keypair) -> Result<()> {
- delete_file(&self.storage.accounts_directory.file_path, key.pubkey.hex())
+ delete_file(&self.storage.accounts_directory.file_path, key.pubkey.hex())?;
+ self.storage.keyring.remove_secret(&key.pubkey)?;
+ Ok(())
}
pub fn select_key(&self, pubkey: Option<Pubkey>) -> Result<()> {
@@ -86,14 +121,33 @@ impl AccountStorageReader {
}
pub fn get_accounts(&self) -> Result<Vec<UserAccountSerializable>> {
- let keys = self
+ let accounts = self
.storage
.accounts_directory
.get_files()?
.values()
- .filter_map(|serialized| deserialize_storage(serialized).ok())
- .collect();
- Ok(keys)
+ .filter_map(|serialized| match deserialize_storage(serialized) {
+ Ok(account) => Some(account),
+ Err(err) => {
+ tracing::error!("failed to deserialize stored account: {err}");
+ None
+ }
+ })
+ // sanitize our storage of secrets & inject the secret from `keyring` into `UserAccountSerializable`
+ .map(|mut account| -> Result<UserAccountSerializable> {
+ if let Some(secret) = &account.key.secret_key {
+ self.storage
+ .keyring
+ .store_secret(&account.key.pubkey, secret)?;
+ self.storage.write_account_without_secret(&account)?;
+ } else if let Some(secret) = self.storage.keyring.get_secret(&account.key.pubkey)? {
+ account.key.secret_key = Some(secret);
+ }
+ Ok(account)
+ })
+ .collect::<Result<Vec<_>>>()?;
+
+ Ok(accounts)
}
pub fn get_selected_key(&self) -> Result<Option<Pubkey>> {
@@ -127,10 +181,18 @@ fn old_deserialization(serialized: &str) -> Result<Keypair> {
Ok(serde_json::from_str::<SerializableKeypair>(serialized)?.to_keypair(""))
}
+fn sanitized_account(account: &UserAccountSerializable) -> UserAccountSerializable {
+ let mut sanitized = account.clone();
+ sanitized.key.secret_key = None;
+ sanitized
+}
+
#[cfg(test)]
mod tests {
use std::path::PathBuf;
+ use crate::storage::KeyringStore;
+
use super::Result;
use super::*;
@@ -139,10 +201,11 @@ mod tests {
impl AccountStorage {
fn mock() -> Result<Self> {
- Ok(Self {
- accounts_directory: Directory::new(CREATE_TMP_DIR()?),
- selected_key_directory: Directory::new(CREATE_TMP_DIR()?),
- })
+ Ok(Self::with_keyring(
+ Directory::new(CREATE_TMP_DIR()?),
+ Directory::new(CREATE_TMP_DIR()?),
+ KeyringStore::in_memory(),
+ ))
}
}
diff --git a/crates/notedeck/src/user_account.rs b/crates/notedeck/src/user_account.rs
@@ -39,6 +39,7 @@ impl UserAccount {
}
}
+#[derive(Clone)]
pub struct UserAccountSerializable {
pub key: Keypair,
pub wallet: Option<WalletSerializable>,