use std::{
collections::HashMap,
fs,
fs::{File, create_dir_all},
io::prelude::*,
path::{Path, PathBuf},
str,
sync::{Arc, Mutex},
};
use chrono::prelude::*;
use config::Config;
use totp_rs::TOTP;
use zeroize::Zeroize;
use crate::{
crypto::{Crypto, CryptoImpl, GpgMe, Sequoia, VerificationError},
git::{
add_and_commit_internal, commit, find_last_commit, init_git_repo, match_with_parent,
move_and_commit, push_password_if_match, read_git_meta_data, remove_and_commit,
verify_git_signature,
},
};
pub use crate::{
error::{Error, Result, to_result},
signature::{
Comment, KeyRingStatus, OwnerTrustLevel, Recipient, SignatureStatus, parse_signing_keys,
},
};
pub struct PasswordStore {
name: String,
root: PathBuf,
valid_gpg_signing_keys: Vec<[u8; 20]>,
pub passwords: Vec<PasswordEntry>,
style_file: Option<PathBuf>,
crypto: Box<dyn Crypto + Send>,
user_home: Option<PathBuf>,
}
impl Default for PasswordStore {
fn default() -> Self {
Self {
name: "default".to_owned(),
root: PathBuf::from("/tmp/"),
valid_gpg_signing_keys: vec![],
passwords: vec![],
style_file: None,
crypto: Box::new(GpgMe {}),
user_home: None,
}
}
}
impl PasswordStore {
pub fn new(
store_name: &str,
password_store_dir: &Option<PathBuf>,
password_store_signing_key: &Option<String>,
home: &Option<PathBuf>,
style_file: &Option<PathBuf>,
crypto_impl: &CryptoImpl,
own_fingerprint: &Option<[u8; 20]>,
) -> Result<Self> {
let pass_home = password_dir_raw(password_store_dir, home);
if !pass_home.exists() {
return Err(Error::Generic("failed to locate password directory"));
}
let crypto: Box<dyn Crypto + Send> = match crypto_impl {
CryptoImpl::GpgMe => Box::new(GpgMe {}),
CryptoImpl::Sequoia => {
let home: PathBuf = home.clone().ok_or(Error::Generic(
"no home, required for using Sequoia as pgp implementation",
))?;
Box::new(Sequoia::new(
&home.join(".local"),
own_fingerprint.ok_or_else(|| Error::Generic("own_fingerprint is not configured, required for using Sequoia as pgp implementation"))?,
&home,
)?)
}
};
let signing_keys = parse_signing_keys(password_store_signing_key, crypto.as_ref())?;
let store = Self {
name: store_name.to_owned(),
root: pass_home.canonicalize()?,
valid_gpg_signing_keys: signing_keys,
passwords: [].to_vec(),
style_file: style_file.to_owned(),
crypto,
user_home: home.clone(),
};
if !store.valid_gpg_signing_keys.is_empty() {
store.verify_gpg_id_files()?;
}
Ok(store)
}
pub fn create(
store_name: &str,
password_store_dir: &Option<PathBuf>,
recipients: &[Recipient],
recipients_as_signers: bool,
home: &Option<PathBuf>,
style_file: &Option<PathBuf>,
) -> Result<Self> {
let pass_home = password_dir_raw(password_store_dir, home);
if pass_home.exists() {
return Err(Error::Generic(
"trying to create a pass store in an existing directory",
));
}
if recipients.is_empty() {
return Err(Error::Generic(
"password store must have at least one member",
));
}
for recipient in recipients {
if recipient.key_id.len() != 40 && recipient.key_id.len() != 42 {
return Err(Error::Generic(
"member specification wasn't a full pgp fingerprint",
));
}
}
let crypto = Box::new(GpgMe {});
let signing_keys = {
if recipients_as_signers {
let mut fingerprints = vec![];
for r in recipients {
fingerprints.push(r.fingerprint.ok_or_else(|| {
Error::GenericDyn(format!(
"recipient {} ({}) doesn't have a fingerprint",
r.name, r.key_id
))
})?);
}
fingerprints
} else {
vec![]
}
};
create_dir_all(&pass_home)?;
Recipient::write_recipients_file(
recipients,
&pass_home.join(".gpg-id"),
&signing_keys,
crypto.as_ref(),
)?;
let repo = init_git_repo(&pass_home)?;
if recipients_as_signers {
add_and_commit_internal(
&repo,
&[PathBuf::from(".gpg-id"), PathBuf::from(".gpg-id.sig")],
"initial commit by Ripasso",
crypto.as_ref(),
)?;
} else {
add_and_commit_internal(
&repo,
&[PathBuf::from(".gpg-id")],
"initial commit by Ripasso",
crypto.as_ref(),
)?;
}
let store = Self {
name: store_name.to_owned(),
root: pass_home.canonicalize()?,
valid_gpg_signing_keys: signing_keys,
passwords: [].to_vec(),
style_file: style_file.to_owned(),
crypto,
user_home: home.clone(),
};
Ok(store)
}
pub fn get_name(&self) -> &String {
&self.name
}
pub fn get_valid_gpg_signing_keys(&self) -> &Vec<[u8; 20]> {
&self.valid_gpg_signing_keys
}
pub fn get_store_path(&self) -> PathBuf {
self.root.clone()
}
pub fn get_user_home(&self) -> Option<PathBuf> {
self.user_home.clone()
}
pub fn get_style_file(&self) -> Option<PathBuf> {
self.style_file.clone()
}
pub fn get_crypto(&self) -> &(dyn Crypto + Send) {
&*self.crypto
}
pub fn repo(&self) -> Result<git2::Repository> {
Ok(git2::Repository::open(&self.root)?)
}
fn verify_gpg_id_files(&self) -> Result<SignatureStatus> {
let mut result = SignatureStatus::Good;
for gpg_id_file in self.recipients_files()? {
let mut gpg_id_sig_file = self.root.clone();
gpg_id_sig_file.push(".gpg-id.sig");
let gpg_id = fs::read(gpg_id_file)?;
let gpg_id_sig = match fs::read(gpg_id_sig_file) {
Ok(c) => c,
Err(_) => {
return Err(Error::Generic(
"problem reading .gpg-id.sig, and strict signature checking was asked for",
));
}
};
match self
.crypto
.verify_sign(&gpg_id, &gpg_id_sig, &self.valid_gpg_signing_keys)
{
Ok(r) => match r {
SignatureStatus::Good => {}
SignatureStatus::AlmostGood => result = SignatureStatus::AlmostGood,
SignatureStatus::Bad => return Ok(SignatureStatus::Bad),
},
Err(VerificationError::InfrastructureError(message)) => {
return Err(Error::GenericDyn(message));
}
Err(VerificationError::SignatureFromWrongRecipient) => {
return Err(Error::Generic(
"the .gpg-id file wasn't signed by one of the keys specified in the environmental variable PASSWORD_STORE_SIGNING_KEY",
));
}
Err(VerificationError::BadSignature) => {
return Err(Error::Generic("Bad signature for .gpg-id file"));
}
Err(VerificationError::MissingSignatures) => {
return Err(Error::Generic(
"Missing signature for .gpg-id file, and PASSWORD_STORE_SIGNING_KEY specified",
));
}
Err(VerificationError::TooManySignatures) => {
return Err(Error::Generic(
"Signature for .gpg-id file contained more than one signature, something is fishy",
));
}
}
}
Ok(result)
}
fn verify_gpg_id_file_for_path(&self, path: &Path) -> Result<SignatureStatus> {
let gpg_id_file = self.recipients_file_for_dir(path)?;
let gpg_id_sig_file = {
let mut sig = gpg_id_file.clone();
sig.pop();
sig.join(".gpg-id.sig")
};
let gpg_id = fs::read(gpg_id_file)?;
let gpg_id_sig = match fs::read(gpg_id_sig_file) {
Ok(c) => c,
Err(_) => {
return Err(Error::Generic(
"problem reading .gpg-id.sig, and strict signature checking was asked for",
));
}
};
match self
.crypto
.verify_sign(&gpg_id, &gpg_id_sig, &self.valid_gpg_signing_keys)
{
Ok(r) => Ok(r),
Err(VerificationError::InfrastructureError(message)) => Err(Error::GenericDyn(message)),
Err(VerificationError::SignatureFromWrongRecipient) => Err(Error::Generic(
"the .gpg-id file wasn't signed by one of the keys specified in the environmental variable PASSWORD_STORE_SIGNING_KEY",
)),
Err(VerificationError::BadSignature) => {
Err(Error::Generic("Bad signature for .gpg-id file"))
}
Err(VerificationError::MissingSignatures) => Err(Error::Generic(
"Missing signature for .gpg-id file, and PASSWORD_STORE_SIGNING_KEY specified",
)),
Err(VerificationError::TooManySignatures) => Err(Error::Generic(
"Signature for .gpg-id file contained more than one signature, something is fishy",
)),
}
}
pub fn new_password_file(&mut self, path_end: &str, content: &str) -> Result<PasswordEntry> {
let mut path = self.root.clone();
let c_path = fs::canonicalize(path.as_path())?;
let path_iter = &mut path_end.split('/').peekable();
while let Some(p) = path_iter.next() {
if path_iter.peek().is_some() {
path.push(p);
let c_file_res = fs::canonicalize(path.as_path());
if let Ok(c_file) = c_file_res {
if !c_file.starts_with(c_path.as_path()) {
return Err(Error::Generic(
"trying to write outside of password store directory",
));
}
}
if !path.exists() {
fs::create_dir(&path)?;
}
} else {
path.push(format!("{p}.gpg"));
}
}
if path.exists() {
return Err(Error::Generic("file already exist"));
}
match self.new_password_file_internal(&path, path_end, content) {
Ok(pe) => Ok(pe),
Err(err) => {
let _ = fs::remove_file(path);
Err(err)
}
}
}
fn new_password_file_internal(
&mut self,
path: &Path,
path_end: &str,
content: &str,
) -> Result<PasswordEntry> {
let mut file = File::create(path)?;
if !self.valid_gpg_signing_keys.is_empty() {
self.verify_gpg_id_files()?;
}
let recipients = self.recipients_for_path(path)?;
let output = self.crypto.encrypt_string(content, &recipients)?;
if let Err(why) = file.write_all(&output) {
return Err(Error::from(why));
}
match self.repo() {
Err(_) => {
self.passwords.push(PasswordEntry::load_from_filesystem(
&self.root,
&append_extension(PathBuf::from(path_end), ".gpg"),
));
Ok(PasswordEntry::load_from_filesystem(
&self.root,
&append_extension(PathBuf::from(path_end), ".gpg"),
))
}
Ok(repo) => {
let message = format!("Add password for {path_end} using ripasso");
add_and_commit_internal(
&repo,
&[append_extension(PathBuf::from(path_end), ".gpg")],
&message,
self.crypto.as_ref(),
)?;
self.passwords
.push(PasswordEntry::load_from_git(&self.root, path, &repo, self));
Ok(PasswordEntry::load_from_git(&self.root, path, &repo, self))
}
}
}
pub fn reload_password_list(&mut self) -> Result<()> {
let mut new_passwords = self.all_passwords()?;
self.passwords.clear();
self.passwords.append(&mut new_passwords);
Ok(())
}
pub fn has_configured_username(&self) -> bool {
if self.repo().is_err() {
return true;
}
if let Ok(repo) = self.repo() {
if let Ok(config) = repo.config() {
let user_name = config.get_string("user.name");
if user_name.is_ok() {
return true;
}
}
}
match git2::Config::open_default() {
Err(_) => false,
Ok(config) => {
let user_name = config.get_string("user.name");
if user_name.is_err() {
return false;
}
true
}
}
}
pub fn all_passwords(&self) -> Result<Vec<PasswordEntry>> {
let mut passwords = vec![];
let repo = self.repo();
if repo.is_err() {
let password_path_glob = self.root.join("**/*.gpg");
let existing_iter = glob::glob(&password_path_glob.to_string_lossy())?;
for existing_file in existing_iter {
let relpath = existing_file?.strip_prefix(&self.root)?.to_path_buf();
passwords.push(PasswordEntry::load_from_filesystem(&self.root, &relpath));
}
return Ok(passwords);
}
let repo = repo?;
let password_path_glob = self.root.join("**/*.gpg");
let existing_iter = glob::glob(&password_path_glob.to_string_lossy())?;
let mut files_to_find: Vec<PathBuf> = vec![];
for existing_file in existing_iter {
files_to_find.push(existing_file?.strip_prefix(&self.root)?.to_path_buf());
}
if files_to_find.is_empty() {
return Ok(vec![]);
}
let mut walk = repo.revwalk()?;
walk.push(repo.head()?.target().ok_or("missing Oid on head")?)?;
let mut last_tree = repo
.find_commit(repo.head()?.target().ok_or("missing Oid on head")?)?
.tree()?;
let mut last_commit = repo.head()?.peel_to_commit()?;
for rev in walk {
if rev.is_err() {
continue;
}
let oid = rev?;
let commit = repo.find_commit(oid)?;
let tree = commit.tree()?;
let diff = repo.diff_tree_to_tree(Some(&last_tree), Some(&tree), None)?;
diff.foreach(
&mut |delta: git2::DiffDelta, _f: f32| {
if let Some(found) = delta.new_file().path() {
files_to_find.retain(|target| {
push_password_if_match(
target,
found,
&commit,
&repo,
&mut passwords,
&oid,
self,
)
});
}
true
},
None,
None,
None,
)?;
last_tree = tree;
last_commit = commit;
}
last_tree.walk(git2::TreeWalkMode::PreOrder, |path, entry| {
if let Some(entry_name) = entry.name() {
let found = Path::new(path).join(entry_name);
files_to_find.retain(|target| {
push_password_if_match(
target,
&found,
&last_commit,
&repo,
&mut passwords,
&last_commit.id(),
self,
)
});
}
git2::TreeWalkResult::Ok
})?;
for not_found in files_to_find {
passwords.push(PasswordEntry::new(
&self.root,
¬_found.clone(),
Err(Error::Generic("")),
Err(Error::Generic("")),
Err(Error::Generic("")),
RepositoryStatus::NotInRepo,
));
}
Ok(passwords)
}
pub fn all_recipients(&self) -> Result<Vec<Recipient>> {
if !self.valid_gpg_signing_keys.is_empty() {
self.verify_gpg_id_files()?;
}
let mut recipients = vec![];
for file in self.recipients_files()? {
for r in Recipient::all_recipients(&file, self.crypto.as_ref())? {
if !recipients.contains(&r) {
recipients.push(r);
}
}
}
Ok(recipients)
}
pub fn recipients_for_path(&self, path: &Path) -> Result<Vec<Recipient>> {
if !self.valid_gpg_signing_keys.is_empty() {
self.verify_gpg_id_file_for_path(path)?;
}
Recipient::all_recipients(&self.recipients_file_for_dir(path)?, self.crypto.as_ref())
}
fn recipients_file_for_dir(&self, path: &Path) -> Result<PathBuf> {
let mut new_dir = fs::canonicalize(self.root.join(path))?;
let root = fs::canonicalize(&self.root)?;
if !new_dir.starts_with(&root) {
return Err(Error::Generic("path traversal is not allowed"));
}
while new_dir.starts_with(&root) {
let f = new_dir.join(".gpg-id");
if f.exists() {
return Ok(f);
}
new_dir.pop();
}
Err(Error::Generic("No .gpg-id file found"))
}
fn visit_dirs(dir: &Path, result: &mut Vec<PathBuf>) -> Result<()> {
if dir.is_dir() {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
Self::visit_dirs(&path, result)?;
} else if entry.file_name() == ".gpg-id" {
result.push(entry.path());
}
}
}
Ok(())
}
fn recipients_files(&self) -> Result<Vec<PathBuf>> {
let mut results = vec![];
Self::visit_dirs(&self.root, &mut results)?;
Ok(results)
}
fn remove_recipient_inner(&self, r: &Recipient, path: &Path) -> Result<()> {
Recipient::remove_recipient_from_file(
r,
&self.recipients_file_for_dir(path)?,
&self.root,
&self.valid_gpg_signing_keys,
self.crypto.as_ref(),
)?;
self.reencrypt_all_password_entries()
}
pub fn remove_recipient(&self, r: &Recipient, path: &Path) -> Result<()> {
let gpg_id_file = &self.recipients_file_for_dir(path)?;
let gpg_id_file_content = fs::read_to_string(gpg_id_file)?;
let res = self.remove_recipient_inner(r, path);
if res.is_err() {
fs::write(gpg_id_file, gpg_id_file_content)?;
}
res
}
pub fn add_recipient(&mut self, r: &Recipient, path: &Path, config_path: &Path) -> Result<()> {
if !self.crypto.is_key_in_keyring(r)? {
self.crypto.pull_keys(&[r], config_path)?;
}
if !self.crypto.is_key_in_keyring(r)? {
return Err(Error::Generic(
"Key isn't in keyring and couldn't be downloaded from keyservers",
));
}
let dir = self.root.join(path);
if !dir.exists() {
return Err(Error::Generic("path doesn't exist"));
}
let dir = fs::canonicalize(self.root.join(path))?;
let root = fs::canonicalize(&self.root)?;
if !dir.starts_with(root) {
return Err(Error::Generic("path traversal not allowed"));
}
if !dir.join(".gpg-id").exists() {
File::create(dir.join(".gpg-id"))?;
}
Recipient::add_recipient_to_file(
r,
&self.recipients_file_for_dir(path)?,
&self.valid_gpg_signing_keys,
self.crypto.as_ref(),
)?;
self.reencrypt_all_password_entries()
}
fn reencrypt_all_password_entries(&self) -> Result<()> {
let mut names: Vec<PathBuf> = Vec::new();
for entry in self.all_passwords()? {
let mut secret = entry.secret(self)?;
entry.update_internal(&secret, self)?;
secret.zeroize();
names.push(append_extension(PathBuf::from(&entry.name), ".gpg"));
}
names.push(PathBuf::from(".gpg-id"));
if self.repo().is_err() {
return Ok(());
}
let keys = self
.all_recipients()?
.into_iter()
.fold(String::new(), |mut acc, r| {
use std::fmt::Write;
let _ = write!(acc, ", 0x{}", r.key_id);
acc
});
let message = format!("Reencrypt password store with new GPG ids {keys}");
self.add_and_commit(&names, &message)?;
Ok(())
}
pub fn add_and_commit(&self, paths: &[PathBuf], message: &str) -> Result<git2::Oid> {
let repo = self.repo();
if repo.is_err() {
return Err(Error::Generic("must have a repository"));
}
let repo = repo?;
let mut index = repo.index()?;
for path in paths {
index.add_path(path)?;
}
let oid = index.write_tree()?;
let signature = repo.signature()?;
let parent_commit_res = find_last_commit(&repo);
let mut parents = vec![];
let parent_commit;
if parent_commit_res.is_ok() {
parent_commit = parent_commit_res?;
parents.push(&parent_commit);
}
let tree = repo.find_tree(oid)?;
let oid = commit(
&repo,
&signature,
message,
&tree,
&parents,
self.crypto.as_ref(),
)?;
let obj = repo.find_object(oid, None)?;
repo.reset(&obj, git2::ResetType::Hard, None)?;
Ok(oid)
}
pub fn rename_file(&mut self, old_name: &str, new_name: &str) -> Result<usize> {
if new_name.starts_with('/') || new_name.contains("..") {
return Err(Error::Generic("directory traversal not allowed"));
}
let mut old_path = self.root.clone();
old_path.push(PathBuf::from(old_name));
let old_path = append_extension(old_path, ".gpg");
let mut new_path = self.root.clone();
new_path.push(PathBuf::from(new_name));
let new_path = append_extension(new_path, ".gpg");
if !old_path.exists() {
return Err(Error::Generic("source file is missing"));
}
if new_path.exists() {
return Err(Error::Generic("can't target file already exists"));
}
let mut new_path_dir = new_path.clone();
new_path_dir.pop();
create_dir_all(&new_path_dir)?;
let mut file = File::create(&new_path)?;
let mut secret = self.crypto.decrypt_string(&fs::read(&old_path)?)?;
let new_recipients = Recipient::all_recipients(
&self.recipients_file_for_dir(&new_path)?,
self.crypto.as_ref(),
)?;
file.write_all(&self.crypto.encrypt_string(&secret, &new_recipients)?)?;
secret.zeroize();
fs::remove_file(&old_path)?;
if self.repo().is_ok() {
let old_file_name = append_extension(PathBuf::from(old_name), ".gpg");
let new_file_name = append_extension(PathBuf::from(new_name), ".gpg");
move_and_commit(self, &old_file_name, &new_file_name, "moved file")?;
}
let passwords = &mut self.passwords;
let mut index = usize::MAX;
for (i, entry) in passwords.iter().enumerate() {
if entry.name == old_name {
index = i;
}
}
if index != usize::MAX {
let old_entry = passwords.swap_remove(index);
let relpath = new_path.strip_prefix(&self.root)?.to_path_buf();
let new_entry = PasswordEntry::with_new_name(old_entry, &self.root, &relpath);
passwords.push(new_entry);
}
Ok(passwords.len() - 1)
}
pub fn recipient_from(
&self,
key_id: &str,
pre_comment: &[String],
post_comment: Option<String>,
) -> Result<Recipient> {
Recipient::from(key_id, pre_comment, post_comment, self.crypto.as_ref())
}
}
pub fn all_recipients_from_stores(
stores: Arc<Mutex<Vec<Arc<Mutex<PasswordStore>>>>>,
) -> Result<Vec<Recipient>> {
let all_recipients: Vec<Recipient> = {
let mut ar: HashMap<String, Recipient> = HashMap::new();
let stores = stores
.lock()
.map_err(|_e| Error::Generic("problem locking the mutex"))?;
#[allow(clippy::significant_drop_in_scrutinee)]
for store in stores.iter() {
let store = store
.lock()
.map_err(|_e| Error::Generic("problem locking the mutex"))?;
#[allow(clippy::significant_drop_in_scrutinee)]
for recipient in store.all_recipients()? {
let key = match recipient.fingerprint.as_ref() {
None => recipient.key_id.clone(),
Some(fingerprint) => hex::encode_upper(fingerprint),
};
ar.insert(key, recipient);
}
}
ar.into_values().collect()
};
Ok(all_recipients)
}
#[non_exhaustive]
pub struct GitLogLine {
pub message: String,
pub commit_time: DateTime<Local>,
pub signature_status: Option<SignatureStatus>,
}
impl GitLogLine {
pub fn new(
message: String,
commit_time: DateTime<Local>,
signature_status: Option<SignatureStatus>,
) -> Self {
Self {
message,
commit_time,
signature_status,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum RepositoryStatus {
InRepo,
NotInRepo,
#[default]
NoRepo,
}
#[derive(Clone, Debug, Default)]
pub struct PasswordEntry {
pub name: String,
pub path: PathBuf,
pub updated: Option<DateTime<Local>>,
pub committed_by: Option<String>,
pub signature_status: Option<SignatureStatus>,
pub is_in_git: RepositoryStatus,
}
fn to_name(relpath: &Path) -> String {
let mut s = relpath.to_string_lossy().to_string();
if relpath
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("gpg"))
{
s.truncate(s.len() - 4);
s
} else {
s
}
}
impl PasswordEntry {
pub fn new(
base: &Path, relpath: &Path, update_time: Result<DateTime<Local>>,
committed_by: Result<String>,
signature_status: Result<SignatureStatus>,
is_in_git: RepositoryStatus,
) -> Self {
Self {
name: to_name(relpath),
path: base.join(relpath),
updated: update_time.ok(),
committed_by: committed_by.ok(),
signature_status: signature_status.ok(),
is_in_git,
}
}
pub fn with_new_name(old: Self, base: &Path, relpath: &Path) -> Self {
Self {
name: to_name(relpath),
path: base.join(relpath),
updated: old.updated,
committed_by: old.committed_by,
signature_status: old.signature_status,
is_in_git: old.is_in_git,
}
}
pub fn load_from_git(
base: &Path,
path: &Path,
repo: &git2::Repository,
store: &PasswordStore,
) -> Self {
let (update_time, committed_by, signature_status) =
read_git_meta_data(base, path, repo, store);
let relpath = path
.strip_prefix(base)
.expect("base was not a prefix of path")
.to_path_buf();
Self::new(
base,
&relpath,
update_time,
committed_by,
signature_status,
RepositoryStatus::InRepo,
)
}
pub fn load_from_filesystem(base: &Path, relpath: &Path) -> Self {
Self {
name: to_name(relpath),
path: base.join(relpath),
updated: None,
committed_by: None,
signature_status: None,
is_in_git: RepositoryStatus::NoRepo,
}
}
pub fn secret(&self, store: &PasswordStore) -> Result<String> {
let s = fs::metadata(&self.path)?;
if s.len() == 0 {
return Err(Error::Generic("empty password file"));
}
let content = fs::read(&self.path)?;
store.crypto.decrypt_string(&content)
}
pub fn password(&self, store: &PasswordStore) -> Result<String> {
let mut secret = self.secret(store)?;
let password: String = secret.split('\n').take(1).collect();
secret.zeroize();
Ok(password)
}
pub fn mfa(&self, store: &PasswordStore) -> Result<String> {
let mut secret = self.secret(store)?;
if let Some(start_pos) = secret.find("otpauth://") {
let end_pos = {
let mut end_pos = secret.len();
for (pos, c) in secret.chars().skip(start_pos).enumerate() {
if c.is_whitespace() {
end_pos = pos + start_pos;
break;
}
}
end_pos
};
let totp = TOTP::from_url_unchecked(&secret[start_pos..end_pos])?;
secret.zeroize();
Ok(totp.generate_current()?)
} else {
secret.zeroize();
Err(Error::Generic("No otpauth:// url in secret"))
}
}
fn update_internal(&self, secret: &str, store: &PasswordStore) -> Result<()> {
if !store.valid_gpg_signing_keys.is_empty() {
store.verify_gpg_id_files()?;
}
let recipients = store.recipients_for_path(&self.path)?;
let ciphertext = store.crypto.encrypt_string(secret, &recipients)?;
let mut output = File::create(&self.path)?;
output.write_all(&ciphertext)?;
Ok(())
}
pub fn update(&self, mut secret: String, store: &PasswordStore) -> Result<()> {
self.update_internal(&secret, store)?;
secret.zeroize();
if store.repo().is_err() {
return Ok(());
}
let message = format!("Edit password for {} using ripasso", &self.name);
store.add_and_commit(
&[append_extension(PathBuf::from(&self.name), ".gpg")],
&message,
)?;
Ok(())
}
pub fn delete_file(&self, store: &PasswordStore) -> Result<()> {
fs::remove_file(&self.path)?;
if store.repo().is_err() {
return Ok(());
}
let message = format!("Removed password file for {} using ripasso", &self.name);
remove_and_commit(
store,
&[append_extension(PathBuf::from(&self.name), ".gpg")],
&message,
)?;
Ok(())
}
pub fn get_history(&self, store: &PasswordStore) -> Result<Vec<GitLogLine>> {
let repo = {
let repo_res = store.repo();
if repo_res.is_err() {
return Ok(vec![]);
}
repo_res?
};
let mut revwalk = repo.revwalk()?;
revwalk.set_sorting(git2::Sort::REVERSE)?;
revwalk.set_sorting(git2::Sort::TIME)?;
revwalk.push_head()?;
let p = self.path.strip_prefix(&store.root)?;
let ps = git2::Pathspec::new(vec![&p])?;
let mut diffopts = git2::DiffOptions::new();
diffopts.pathspec(p);
let walk_res: Vec<GitLogLine> = revwalk
.filter_map(|id| {
if let Ok(oid) = id {
if let Ok(commit) = repo.find_commit(oid) {
if commit.parents().len() == 0 {
if let Ok(tree) = commit.tree() {
let flags = git2::PathspecFlags::NO_MATCH_ERROR;
ps.match_tree(&tree, flags).ok()?;
} else {
return None;
}
} else {
let m = commit.parents().all(|parent| {
match_with_parent(&repo, &commit, &parent, &mut diffopts)
.unwrap_or(false)
});
if !m {
return None;
}
}
let time = commit.time();
let dt = to_result(Local.timestamp_opt(time.seconds(), 0)).ok()?;
let signature_status = verify_git_signature(&repo, &oid, store);
Some(GitLogLine::new(
commit.message().unwrap_or("<no message>").to_owned(),
dt,
signature_status.ok(),
))
} else {
None
}
} else {
None
}
})
.collect();
Ok(walk_res)
}
}
pub fn pgp_pull(store: &mut PasswordStore, config_path: &Path) -> Result<String> {
let recipients = store.all_recipients()?;
let recipients_refs: Vec<&Recipient> = recipients.iter().collect();
let result = store.crypto.pull_keys(&recipients_refs, config_path)?;
Ok(result)
}
pub fn pgp_import(store: &mut PasswordStore, text: &str, config_path: &Path) -> Result<String> {
store.crypto.import_key(text, config_path)
}
pub fn search(store: &PasswordStore, query: &str) -> Vec<PasswordEntry> {
let passwords = &store.passwords;
fn normalized(s: &str) -> String {
s.to_lowercase()
}
fn matches(s: &str, q: &str) -> bool {
normalized(s).as_str().contains(normalized(q).as_str())
}
let matching = passwords.iter().filter(|p| matches(&p.name, query));
matching.cloned().collect()
}
pub fn password_dir(
password_store_dir: &Option<PathBuf>,
home: &Option<PathBuf>,
) -> Result<PathBuf> {
let pass_home = password_dir_raw(password_store_dir, home);
if !pass_home.exists() {
return Err(Error::Generic("failed to locate password directory"));
}
Ok(pass_home)
}
pub fn password_dir_raw(password_store_dir: &Option<PathBuf>, home: &Option<PathBuf>) -> PathBuf {
match password_store_dir.as_ref() {
Some(p) => p.clone(),
None => match home {
Some(h) => h.join(".password-store"),
None => PathBuf::new().join(".password-store"),
},
}
}
fn home_exists(home: &Option<PathBuf>, settings: &Config) -> bool {
if home.is_none() {
return false;
}
let home = home.as_ref().unwrap();
let default_password_store_dir = home.join(".password-store");
if !default_password_store_dir.exists() {
return false;
}
if !default_password_store_dir.is_dir() {
return false;
}
let stores_res = settings.get("stores");
if let Ok(stores) = stores_res {
let stores: HashMap<String, config::Value> = stores;
for store_name in stores.keys() {
let store: HashMap<String, config::Value> =
stores[store_name].clone().into_table().unwrap();
let password_store_dir_opt = store.get("path");
if let Some(p) = password_store_dir_opt {
let p_path = PathBuf::from(p.clone().into_string().unwrap());
let c1 = fs::canonicalize(default_password_store_dir.clone());
let c2 = fs::canonicalize(p_path);
if c1.is_ok() && c2.is_ok() && c1.unwrap() == c2.unwrap() {
return false;
}
}
}
}
true
}
fn env_var_exists(store_dir: &Option<String>, signing_keys: &Option<String>) -> bool {
store_dir.is_some() || signing_keys.is_some()
}
fn settings_file_exists(home: &Option<PathBuf>, xdg_config_home: &Option<PathBuf>) -> bool {
if home.is_none() {
return false;
}
let home = home.as_ref().unwrap();
let xdg_config_file = match xdg_config_home.as_ref() {
Some(p) => p.join("ripasso/settings.toml"),
None => home.join(".config/ripasso/settings.toml"),
};
let xdg_config_file_dir = Path::new(&xdg_config_file);
if xdg_config_file_dir.exists() {
return fs::metadata(xdg_config_file_dir).is_ok_and(|config_file| config_file.len() != 0);
}
false
}
fn home_settings(home: &Option<PathBuf>) -> Result<Config> {
let mut default_store = HashMap::new();
let home = home.as_ref().ok_or("no home directory set")?;
default_store.insert(
"path".to_owned(),
home.join(".password-store/").to_string_lossy().to_string(),
);
let mut stores_map = HashMap::new();
stores_map.insert("default".to_owned(), default_store);
let mut new_settings = config::ConfigBuilder::default();
new_settings = new_settings.set_default("stores", stores_map)?;
Ok(config::ConfigBuilder::<config::builder::DefaultState>::build(new_settings)?)
}
fn var_settings(store_dir: &Option<String>, signing_keys: &Option<String>) -> Result<Config> {
let mut default_store = HashMap::new();
if let Some(dir) = store_dir {
if dir.ends_with('/') {
default_store.insert("path".to_owned(), dir.clone());
} else {
default_store.insert("path".to_owned(), dir.clone() + "/");
}
}
if let Some(keys) = signing_keys {
default_store.insert("valid_signing_keys".to_owned(), keys.clone());
} else {
default_store.insert("valid_signing_keys".to_owned(), "-1".to_owned());
}
let mut stores_map = HashMap::new();
stores_map.insert("default".to_owned(), default_store);
let mut new_settings = config::ConfigBuilder::default();
new_settings = new_settings.set_default("stores", stores_map)?;
Ok(config::ConfigBuilder::<config::builder::DefaultState>::build(new_settings)?)
}
fn xdg_config_file_location(
home: &Option<PathBuf>,
xdg_config_home: &Option<PathBuf>,
) -> Result<PathBuf> {
match xdg_config_home.as_ref() {
Some(p) => Ok(p.join("ripasso/settings.toml")),
None => {
if let Some(h) = home {
Ok(h.join(".config/ripasso/settings.toml"))
} else {
Err(Error::Generic("no home directory"))
}
}
}
}
fn file_settings(
xdg_config_file: &Path,
) -> config::File<config::FileSourceFile, config::FileFormat> {
config::File::from(xdg_config_file.to_path_buf())
}
fn append_extension(path: PathBuf, extension: &str) -> PathBuf {
let mut str = path.into_os_string();
str.push(extension);
PathBuf::from(str)
}
pub fn read_config(
store_dir: &Option<String>,
signing_keys: &Option<String>,
home: &Option<PathBuf>,
xdg_config_home: &Option<PathBuf>,
) -> Result<(Config, PathBuf)> {
let mut settings = config::ConfigBuilder::default();
let config_file_location = xdg_config_file_location(home, xdg_config_home)?;
if settings_file_exists(home, xdg_config_home) {
settings = config::ConfigBuilder::<config::builder::DefaultState>::add_source(
settings,
file_settings(&config_file_location),
)
}
if home_exists(home, &settings.clone().build()?) {
settings = settings.add_source(home_settings(home)?);
}
if env_var_exists(store_dir, signing_keys) {
settings = settings.add_source(var_settings(store_dir, signing_keys)?);
}
Ok((settings.build()?, config_file_location))
}
pub fn save_config(
stores: Arc<Mutex<Vec<Arc<Mutex<PasswordStore>>>>>,
config_file_location: &Path,
) -> Result<()> {
let mut stores_map = HashMap::new();
let stores_borrowed = stores
.lock()
.map_err(|_e| Error::Generic("problem locking the mutex"))?;
#[allow(clippy::significant_drop_in_scrutinee)]
for store in stores_borrowed.iter() {
let store = store
.lock()
.map_err(|_e| Error::Generic("problem locking the mutex"))?;
let mut store_map = HashMap::new();
store_map.insert(
"path",
store
.get_store_path()
.to_string_lossy()
.into_owned()
.to_string(),
);
if !store.get_valid_gpg_signing_keys().is_empty() {
store_map.insert(
"valid_signing_keys",
store
.get_valid_gpg_signing_keys()
.iter()
.map(hex::encode_upper)
.collect::<Vec<String>>()
.join(","),
);
}
if let Some(style_file) = store.get_style_file() {
store_map.insert("style_path", style_file.display().to_string());
}
store_map.insert(
"pgp_implementation",
store.crypto.implementation().to_string(),
);
if let Some(fp) = store.crypto.own_fingerprint() {
store_map.insert("own_fingerprint", hex::encode_upper(fp));
}
stores_map.insert(store.get_name().clone(), store_map);
}
let mut settings = HashMap::new();
settings.insert("stores", stores_map);
let f = File::create(config_file_location)?;
let mut f = std::io::BufWriter::new(f);
f.write_all(toml::ser::to_string_pretty(&settings)?.as_bytes())?;
Ok(())
}
#[cfg(test)]
#[path = "tests/pass.rs"]
mod pass_tests;