notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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:
Mcrates/notedeck/src/storage/account_storage.rs | 101++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mcrates/notedeck/src/user_account.rs | 1+
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>,