Merge 'impl linux credential storage' #115

From PR description:

See the test_basic() test in linux_key_storage.rs. I ran it successfully
on my MacOS machine and a linux VM. The BasicFileStorage impl just
stores each Keypair as a SerializableKeypair json object with the file
name as the public key hex in ~/.notedeck_credentials. The
SerializableKeypair uses the nip49 EncryptedSecretKey, but we just use
an empty string as the password for now.

The BasicFileStorage impl works in MacOS, but it only conditionally
compiles to linux for simplicity.

pub enum KeyStorageResponse<R> {
    Waiting,
    ReceivedResult(Result<R, KeyStorageError>),
}

This is used as a response so that it's possible for the storage impl to
be async, since secret_service is async. It seems that secret_service
would allow for a more robust linux key storage impl so I went ahead and
made the API compatible with an async impl.

* kernelkind (1):
      impl linux credential storage
This commit is contained in:
William Casarin 2024-07-01 10:42:46 -07:00
commit 4c490e1436
9 changed files with 319 additions and 33 deletions

View File

@ -1,3 +1,7 @@
use nostr::nips::nip49::EncryptedSecretKey;
use serde::Deserialize;
use serde::Serialize;
use crate::Pubkey;
use crate::SecretKey;
@ -93,3 +97,34 @@ impl std::fmt::Display for FullKeypair {
)
}
}
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct SerializableKeypair {
pub pubkey: Pubkey,
pub encrypted_secret_key: Option<EncryptedSecretKey>,
}
impl SerializableKeypair {
pub fn from_keypair(kp: &Keypair, pass: &str, log_n: u8) -> Self {
Self {
pubkey: kp.pubkey.clone(),
encrypted_secret_key: kp
.secret_key
.clone()
.map(|s| {
EncryptedSecretKey::new(&s, pass, log_n, nostr::nips::nip49::KeySecurity::Weak)
.ok()
})
.flatten(),
}
}
pub fn to_keypair(&self, pass: &str) -> Keypair {
Keypair::new(
self.pubkey.clone(),
self.encrypted_secret_key
.map(|e| e.to_secret_key(pass).ok())
.flatten(),
)
}
}

View File

@ -11,7 +11,7 @@ pub use client::ClientMessage;
pub use error::Error;
pub use ewebsock;
pub use filter::Filter;
pub use keypair::{FullKeypair, Keypair};
pub use keypair::{FullKeypair, Keypair, SerializableKeypair};
pub use nostr::SecretKey;
pub use note::{Note, NoteId};
pub use profile::Profile;

View File

@ -2,7 +2,7 @@ use std::cmp::Ordering;
use enostr::Keypair;
use crate::key_storage::KeyStorage;
use crate::key_storage::{KeyStorage, KeyStorageResponse, KeyStorageType};
pub use crate::user_account::UserAccount;
use tracing::info;
@ -11,12 +11,16 @@ use tracing::info;
pub struct AccountManager {
currently_selected_account: Option<usize>,
accounts: Vec<UserAccount>,
key_store: KeyStorage,
key_store: KeyStorageType,
}
impl AccountManager {
pub fn new(currently_selected_account: Option<usize>, key_store: KeyStorage) -> Self {
let accounts = key_store.get_keys().unwrap_or_default();
pub fn new(currently_selected_account: Option<usize>, key_store: KeyStorageType) -> Self {
let accounts = if let KeyStorageResponse::ReceivedResult(res) = key_store.get_keys() {
res.unwrap_or_default()
} else {
Vec::new()
};
AccountManager {
currently_selected_account,

View File

@ -756,7 +756,7 @@ impl Damus {
// TODO: should pull this from settings
None,
// TODO: use correct KeyStorage mechanism for current OS arch
crate::key_storage::KeyStorage::None,
crate::key_storage::KeyStorageType::None,
);
for key in parsed_args.keys {
@ -805,7 +805,7 @@ impl Damus {
timelines,
textmode: false,
ndb: Ndb::new(data_path.as_ref().to_str().expect("db path ok"), &config).expect("ndb"),
account_manager: AccountManager::new(None, crate::key_storage::KeyStorage::None),
account_manager: AccountManager::new(None, crate::key_storage::KeyStorageType::None),
frame_history: FrameHistory::default(),
show_account_switcher: false,
show_global_popup: true,

View File

@ -1,67 +1,88 @@
use enostr::Keypair;
#[cfg(target_os = "linux")]
use crate::linux_key_storage::LinuxKeyStorage;
#[cfg(target_os = "macos")]
use crate::macos_key_storage::MacOSKeyStorage;
#[cfg(target_os = "macos")]
pub const SERVICE_NAME: &str = "Notedeck";
pub enum KeyStorage {
#[derive(Debug, PartialEq)]
pub enum KeyStorageType {
None,
#[cfg(target_os = "macos")]
MacOS,
#[cfg(target_os = "linux")]
Linux,
// TODO:
// Linux,
// Windows,
// Android,
}
impl KeyStorage {
pub fn get_keys(&self) -> Result<Vec<Keypair>, KeyStorageError> {
#[allow(dead_code)]
#[derive(Debug, PartialEq)]
pub enum KeyStorageResponse<R> {
Waiting,
ReceivedResult(Result<R, KeyStorageError>),
}
pub trait KeyStorage {
fn get_keys(&self) -> KeyStorageResponse<Vec<Keypair>>;
fn add_key(&self, key: &Keypair) -> KeyStorageResponse<()>;
fn remove_key(&self, key: &Keypair) -> KeyStorageResponse<()>;
}
impl KeyStorage for KeyStorageType {
fn get_keys(&self) -> KeyStorageResponse<Vec<Keypair>> {
match self {
Self::None => Ok(Vec::new()),
Self::None => KeyStorageResponse::ReceivedResult(Ok(Vec::new())),
#[cfg(target_os = "macos")]
Self::MacOS => Ok(MacOSKeyStorage::new(SERVICE_NAME).get_all_keypairs()),
Self::MacOS => MacOSKeyStorage::new(SERVICE_NAME).get_keys(),
#[cfg(target_os = "linux")]
Self::Linux => LinuxKeyStorage::new().get_keys(),
}
}
pub fn add_key(&self, key: &Keypair) -> Result<(), KeyStorageError> {
fn add_key(&self, key: &Keypair) -> KeyStorageResponse<()> {
let _ = key;
match self {
Self::None => Ok(()),
Self::None => KeyStorageResponse::ReceivedResult(Ok(())),
#[cfg(target_os = "macos")]
Self::MacOS => MacOSKeyStorage::new(SERVICE_NAME).add_key(key),
#[cfg(target_os = "linux")]
Self::Linux => LinuxKeyStorage::new().add_key(key),
}
}
pub fn remove_key(&self, key: &Keypair) -> Result<(), KeyStorageError> {
fn remove_key(&self, key: &Keypair) -> KeyStorageResponse<()> {
let _ = key;
match self {
Self::None => Ok(()),
Self::None => KeyStorageResponse::ReceivedResult(Ok(())),
#[cfg(target_os = "macos")]
Self::MacOS => MacOSKeyStorage::new(SERVICE_NAME).delete_key(&key.pubkey),
Self::MacOS => MacOSKeyStorage::new(SERVICE_NAME).remove_key(key),
#[cfg(target_os = "linux")]
Self::Linux => LinuxKeyStorage::new().remove_key(key),
}
}
}
#[allow(dead_code)]
#[derive(Debug, PartialEq)]
pub enum KeyStorageError {
Retrieval,
Retrieval(String),
Addition(String),
Removal(String),
UnsupportedPlatform,
OSError(String),
}
impl std::fmt::Display for KeyStorageError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::Retrieval => write!(f, "Failed to retrieve keys."),
Self::Retrieval(e) => write!(f, "Failed to retrieve keys: {:?}", e),
Self::Addition(key) => write!(f, "Failed to add key: {:?}", key),
Self::Removal(key) => write!(f, "Failed to remove key: {:?}", key),
Self::UnsupportedPlatform => write!(
f,
"Attempted to use a key storage impl from an unsupported platform."
),
Self::OSError(e) => write!(f, "OS had an error: {:?}", e),
}
}
}

View File

@ -34,6 +34,7 @@ mod user_account;
#[cfg(test)]
#[macro_use]
mod test_utils;
mod linux_key_storage;
pub use app::Damus;
pub use error::Error;

210
src/linux_key_storage.rs Normal file
View File

@ -0,0 +1,210 @@
#![cfg(target_os = "linux")]
use enostr::{Keypair, SerializableKeypair};
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::{env, fs::File};
use crate::key_storage::{KeyStorage, KeyStorageError, KeyStorageResponse};
use tracing::debug;
enum LinuxKeyStorageType {
BasicFileStorage,
// TODO(kernelkind): could use the secret service api, and maybe even allow password manager integration via a settings menu
}
pub struct LinuxKeyStorage {}
// TODO(kernelkind): read from settings instead of hard-coding
static USE_MECHANISM: LinuxKeyStorageType = LinuxKeyStorageType::BasicFileStorage;
impl LinuxKeyStorage {
pub fn new() -> Self {
Self {}
}
}
impl KeyStorage for LinuxKeyStorage {
fn get_keys(&self) -> KeyStorageResponse<Vec<enostr::Keypair>> {
match USE_MECHANISM {
LinuxKeyStorageType::BasicFileStorage => BasicFileStorage::new().get_keys(),
}
}
fn add_key(&self, key: &enostr::Keypair) -> KeyStorageResponse<()> {
match USE_MECHANISM {
LinuxKeyStorageType::BasicFileStorage => BasicFileStorage::new().add_key(key),
}
}
fn remove_key(&self, key: &enostr::Keypair) -> KeyStorageResponse<()> {
match USE_MECHANISM {
LinuxKeyStorageType::BasicFileStorage => BasicFileStorage::new().remove_key(key),
}
}
}
struct BasicFileStorage {
credential_dir_name: String,
}
impl BasicFileStorage {
pub fn new() -> Self {
Self {
credential_dir_name: ".credentials".to_string(),
}
}
fn mock() -> Self {
Self {
credential_dir_name: ".credentials_test".to_string(),
}
}
fn get_cred_dirpath(&self) -> Result<PathBuf, KeyStorageError> {
let home_dir = env::var("HOME")
.map_err(|_| KeyStorageError::OSError("HOME env variable not set".to_string()))?;
let home_path = std::path::PathBuf::from(home_dir);
let project_path_str = "notedeck";
let config_path = {
if let Some(xdg_config_str) = env::var_os("XDG_CONFIG_HOME") {
let xdg_path = PathBuf::from(xdg_config_str);
let xdg_path_config = if xdg_path.is_absolute() {
xdg_path
} else {
home_path.join(".config")
};
xdg_path_config.join(project_path_str)
} else {
home_path.join(format!(".{}", project_path_str))
}
}
.join(self.credential_dir_name.clone());
std::fs::create_dir_all(&config_path).map_err(|_| {
KeyStorageError::OSError(format!(
"could not create config path: {}",
config_path.display()
))
})?;
Ok(config_path)
}
fn add_key_internal(&self, key: &Keypair) -> Result<(), KeyStorageError> {
let mut file_path = self.get_cred_dirpath()?;
file_path.push(format!("{}", &key.pubkey));
let mut file = File::create(file_path)
.map_err(|_| KeyStorageError::Addition("could not create or open file".to_string()))?;
let json_str = serde_json::to_string(&SerializableKeypair::from_keypair(key, "", 7))
.map_err(|e| KeyStorageError::Addition(e.to_string()))?;
file.write_all(json_str.as_bytes()).map_err(|_| {
KeyStorageError::Addition("could not write keypair to file".to_string())
})?;
Ok(())
}
fn get_keys_internal(&self) -> Result<Vec<Keypair>, KeyStorageError> {
let file_path = self.get_cred_dirpath()?;
let mut keys: Vec<Keypair> = Vec::new();
if !file_path.is_dir() {
return Err(KeyStorageError::Retrieval(
"path is not a directory".to_string(),
));
}
let dir = fs::read_dir(file_path).map_err(|_| {
KeyStorageError::Retrieval("problem accessing credentials directory".to_string())
})?;
for entry in dir {
let entry = entry.map_err(|_| {
KeyStorageError::Retrieval("problem accessing crediential file".to_string())
})?;
let path = entry.path();
if path.is_file() {
if let Some(path_str) = path.to_str() {
debug!("key path {}", path_str);
let json_string = fs::read_to_string(path_str).map_err(|e| {
KeyStorageError::OSError(format!("File reading problem: {}", e))
})?;
let key: SerializableKeypair =
serde_json::from_str(&json_string).map_err(|e| {
KeyStorageError::OSError(format!(
"Deserialization problem: {}",
(e.to_string().as_str())
))
})?;
keys.push(key.to_keypair(""))
}
}
}
Ok(keys)
}
fn remove_key_internal(&self, key: &Keypair) -> Result<(), KeyStorageError> {
let path = self.get_cred_dirpath()?;
let filepath = path.join(key.pubkey.to_string());
if filepath.exists() && filepath.is_file() {
fs::remove_file(&filepath)
.map_err(|e| KeyStorageError::OSError(format!("failed to remove file: {}", e)))?;
}
Ok(())
}
}
impl KeyStorage for BasicFileStorage {
fn get_keys(&self) -> crate::key_storage::KeyStorageResponse<Vec<enostr::Keypair>> {
KeyStorageResponse::ReceivedResult(self.get_keys_internal())
}
fn add_key(&self, key: &enostr::Keypair) -> crate::key_storage::KeyStorageResponse<()> {
KeyStorageResponse::ReceivedResult(self.add_key_internal(key))
}
fn remove_key(&self, key: &enostr::Keypair) -> crate::key_storage::KeyStorageResponse<()> {
KeyStorageResponse::ReceivedResult(self.remove_key_internal(key))
}
}
mod tests {
use crate::key_storage::{KeyStorage, KeyStorageResponse};
use super::BasicFileStorage;
#[test]
fn test_basic() {
let kp = enostr::FullKeypair::generate().to_keypair();
let resp = BasicFileStorage::mock().add_key(&kp);
assert_eq!(resp, KeyStorageResponse::ReceivedResult(Ok(())));
assert_num_storage(1);
let resp = BasicFileStorage::mock().remove_key(&kp);
assert_eq!(resp, KeyStorageResponse::ReceivedResult(Ok(())));
assert_num_storage(0);
}
#[allow(dead_code)]
fn assert_num_storage(n: usize) {
let resp = BasicFileStorage::mock().get_keys();
if let KeyStorageResponse::ReceivedResult(Ok(vec)) = resp {
assert_eq!(vec.len(), n);
return;
}
panic!();
}
}

View File

@ -5,7 +5,9 @@ use enostr::{Keypair, Pubkey, SecretKey};
use security_framework::item::{ItemClass, ItemSearchOptions, Limit, SearchResult};
use security_framework::passwords::{delete_generic_password, set_generic_password};
use crate::key_storage::KeyStorageError;
use crate::key_storage::{KeyStorage, KeyStorageError, KeyStorageResponse};
use tracing::error;
pub struct MacOSKeyStorage<'a> {
pub service_name: &'a str,
@ -16,7 +18,7 @@ impl<'a> MacOSKeyStorage<'a> {
MacOSKeyStorage { service_name }
}
pub fn add_key(&self, key: &Keypair) -> Result<(), KeyStorageError> {
fn add_key(&self, key: &Keypair) -> Result<(), KeyStorageError> {
match set_generic_password(
self.service_name,
key.pubkey.hex().as_str(),
@ -52,7 +54,7 @@ impl<'a> MacOSKeyStorage<'a> {
accounts
}
pub fn get_pubkeys(&self) -> Vec<Pubkey> {
fn get_pubkeys(&self) -> Vec<Pubkey> {
self.get_pubkey_strings()
.iter_mut()
.filter_map(|pubkey_str| Pubkey::from_hex(pubkey_str.as_str()).ok())
@ -84,7 +86,7 @@ impl<'a> MacOSKeyStorage<'a> {
}
}
pub fn get_all_keypairs(&self) -> Vec<Keypair> {
fn get_all_keypairs(&self) -> Vec<Keypair> {
self.get_pubkeys()
.iter()
.map(|pubkey| {
@ -94,17 +96,31 @@ impl<'a> MacOSKeyStorage<'a> {
.collect()
}
pub fn delete_key(&self, pubkey: &Pubkey) -> Result<(), KeyStorageError> {
fn delete_key(&self, pubkey: &Pubkey) -> Result<(), KeyStorageError> {
match delete_generic_password(self.service_name, pubkey.hex().as_str()) {
Ok(_) => Ok(()),
Err(e) => {
println!("got error: {}", e);
error!("delete key error {}", e);
Err(KeyStorageError::Removal(pubkey.hex()))
}
}
}
}
impl<'a> KeyStorage for MacOSKeyStorage<'a> {
fn add_key(&self, key: &Keypair) -> KeyStorageResponse<()> {
KeyStorageResponse::ReceivedResult(self.add_key(key))
}
fn get_keys(&self) -> KeyStorageResponse<Vec<Keypair>> {
KeyStorageResponse::ReceivedResult(Ok(self.get_all_keypairs()))
}
fn remove_key(&self, key: &Keypair) -> KeyStorageResponse<()> {
KeyStorageResponse::ReceivedResult(self.delete_key(&key.pubkey))
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -16,9 +16,8 @@ pub struct AccountLoginView<'a> {
impl<'a> View for AccountLoginView<'a> {
fn ui(&mut self, ui: &mut egui::Ui) {
if let Some(key) = self.manager.check_for_successful_login() {
if let Some(_key) = self.manager.check_for_successful_login() {
// TODO: route to "home"
println!("successful login with key: {:?}", key);
/*
return if self.mobile {
// route to "home" on mobile