#![forbid(unsafe_code)]
#![deny(clippy::cargo)]
#![deny(clippy::pedantic)]
#![deny(clippy::nursery)]
#![deny(clippy::panic)]
#![allow(clippy::multiple_crate_versions)]
use std::fs;
use std::fs::File;
use std::io;
use std::path::Path;
use std::process::Command;
use std::{
collections::HashMap,
io::{BufReader, Read, Write},
};
use anyhow::{anyhow, Error, Result};
use clipboard::ClipboardContext;
use clipboard::ClipboardProvider;
use directories_next::ProjectDirs;
use secrecy::{ExposeSecret, Secret};
use serde::{Deserialize, Serialize};
use structopt::StructOpt;
const KEYRING_APP_NAME: &str = "passage";
#[derive(Debug, Deserialize, Serialize)]
struct Storage {
#[serde(flatten)]
entries: HashMap<String, Entry>,
}
#[derive(Debug, Deserialize, Serialize)]
struct Entry {
password: String,
}
enum Hook {
PreLoad,
PostSave,
}
impl Hook {
fn name(&self) -> String {
match *self {
Self::PreLoad => "pre_load".to_string(),
Self::PostSave => "post_save".to_string(),
}
}
}
#[derive(Debug)]
enum HookEvent {
NewEntry,
ListEntries,
ShowEntry,
EditEntry,
RemoveEntry,
}
impl HookEvent {
fn name(&self) -> String {
match *self {
Self::NewEntry => "new_entry".to_string(),
Self::ListEntries => "list_entries".to_string(),
Self::ShowEntry => "show_entry".to_string(),
Self::EditEntry => "edit_entry".to_string(),
Self::RemoveEntry => "remove_entry".to_string(),
}
}
}
#[derive(Debug, StructOpt)]
#[structopt(name = "passage", about = "Password manager with age encryption")]
struct Opt {
#[structopt(flatten)]
cmd: Cmd,
#[structopt(long, short)]
no_keyring: bool,
}
#[derive(Debug, StructOpt)]
enum Cmd {
Init,
New,
List,
Show {
entry: String,
#[structopt(long, short)]
on_screen: bool,
},
Edit { entry: String },
Remove { entry: String },
Info,
Keyring(KeyringOpt),
}
#[derive(Debug, StructOpt)]
enum KeyringOpt {
Check,
Forget,
}
fn storage_dir() -> Result<String> {
match std::env::var("PASSAGE_STORAGE_FOLDER") {
Ok(f) => Ok(f),
Err(_) => match ProjectDirs::from("", "", "passage") {
Some(pd) => {
let dir = pd.data_dir().display().to_string();
Ok(dir)
}
None => Err(anyhow!("couldn't determine project storage folder")),
},
}
}
fn entries_file() -> Result<String> {
Ok(Path::new(&storage_dir()?)
.join("entries.toml.age")
.display()
.to_string())
}
fn hooks_dir() -> Result<String> {
Ok(Path::new(&storage_dir()?)
.join("hooks")
.display()
.to_string())
}
fn encrypt(plaintext: &[u8], passphrase: Secret<String>) -> Result<Vec<u8>, Error> {
let encryptor = age::Encryptor::with_user_passphrase(passphrase);
let mut encrypted = vec![];
let mut writer = encryptor.wrap_output(&mut encrypted).map_err(Error::msg)?;
writer.write_all(plaintext)?;
writer.finish()?;
Ok(encrypted)
}
fn decrypt(encrypted: &[u8], passphrase: &Secret<String>) -> Result<Vec<u8>, Error> {
let decryptor = match age::Decryptor::new(&encrypted[..])? {
age::Decryptor::Passphrase(d) => d,
age::Decryptor::Recipients(..) => unreachable!(),
};
let mut decrypted = vec![];
let mut reader = decryptor.decrypt(passphrase, None)?;
loop {
let bytes = reader.read_to_end(&mut decrypted)?;
if bytes == 0 {
break;
}
}
Ok(decrypted)
}
fn load_entries(passphrase: &Secret<String>) -> Result<Storage> {
let mut encrypted: Vec<u8> = vec![];
let file = match fs::metadata(entries_file()?) {
Ok(_) => File::open(entries_file()?)?,
Err(_) => return Err(anyhow!("storage not initialized, run `passage init`")),
};
let mut buf = BufReader::new(file);
buf.read_to_end(&mut encrypted)?;
if let 0 = encrypted.len() {
Ok(Storage {
entries: HashMap::new(),
})
} else {
let decrypted = decrypt(&encrypted, passphrase)?;
let decrypted = String::from_utf8(decrypted)?;
let decrypted: Storage = toml::from_str(&decrypted)?;
Ok(decrypted)
}
}
fn save_entries(passphrase: Secret<String>, storage: &Storage) -> Result<()> {
let bytes: Vec<u8> = toml::to_vec(&storage)?;
let encrypted = encrypt(&bytes, passphrase)?;
let mut file = File::create(entries_file()?)?;
file.write_all(&encrypted)?;
Ok(())
}
fn new_entry(no_keyring: bool) -> Result<(), Error> {
run_hook(&Hook::PreLoad, &HookEvent::NewEntry)?;
let passphrase = get_passphrase("Passphrase: ", no_keyring)?;
let mut storage = load_entries(&passphrase)?;
print!("New entry: ");
io::stdout().flush()?;
let mut entry = String::new();
io::stdin().read_line(&mut entry)?;
let entry = entry.trim();
if storage.entries.contains_key(entry) {
print!("'{}' already exists. Overwrite (y/N)? ", entry);
io::stdout().flush()?;
let mut overwrite = String::new();
io::stdin().read_line(&mut overwrite)?;
let overwrite = overwrite.trim();
if overwrite.to_uppercase() != "Y" {
return Ok(());
}
}
let password = Secret::new(rpassword::prompt_password_stdout(&format!(
"Password for {}: ",
entry
))?);
storage.entries.insert(
entry.to_owned(),
Entry {
password: password.expose_secret().to_string(),
},
);
save_entries(passphrase, &storage)?;
run_hook(&Hook::PostSave, &HookEvent::NewEntry)?;
Ok(())
}
fn list(no_keyring: bool) -> Result<(), Error> {
run_hook(&Hook::PreLoad, &HookEvent::ListEntries)?;
let passphrase = get_passphrase("Enter passphrase: ", no_keyring)?;
let storage = load_entries(&passphrase)?;
for name in storage.entries.keys() {
println!("{}", name);
}
Ok(())
}
fn init(no_keyring: bool) -> Result<(), Error> {
fs::create_dir_all(storage_dir()?)?;
let path = entries_file()?;
if fs::metadata(path).is_err() {
File::create(entries_file()?)?;
let passphrase = get_passphrase("Passphrase: ", no_keyring)?;
let entries: Storage = toml::from_str("")?;
save_entries(passphrase, &entries)?
}
Ok(())
}
fn get_passphrase(prompt: &str, no_keyring: bool) -> Result<Secret<String>> {
if no_keyring {
let passphrase = rpassword::prompt_password_stdout(prompt)?;
Ok(Secret::new(passphrase))
} else {
get_passphrase_keyring(prompt)
}
}
fn get_passphrase_keyring(prompt: &str) -> Result<Secret<String>> {
let username = &whoami::username();
let keyring = keyring::Keyring::new(KEYRING_APP_NAME, username);
let passphrase = if let Ok(pw) = keyring.get_password() {
Secret::new(pw)
} else {
let passphrase = rpassword::prompt_password_stdout(prompt)?;
if keyring.set_password(&passphrase).is_err() {
anyhow!("Failed to store password in keyring");
}
Secret::new(passphrase)
};
Ok(passphrase)
}
#[cfg(target_os = "linux")]
use fork::{fork, Fork};
#[cfg(target_os = "linux")]
fn copy_to_clipbpard(decrypted: String) -> Result<(), Error> {
match fork() {
Ok(Fork::Child) => {
let mut ctx: ClipboardContext = ClipboardProvider::new()
.map_err(|e| anyhow!("failed to initialize clipboard provider: {}", e))?;
ctx.set_contents(decrypted)
.map_err(|e| anyhow!("failed to copy to clipboard: {}", e))?;
std::thread::sleep(std::time::Duration::from_secs(10));
ctx.set_contents("".to_owned())
.map_err(|e| anyhow!("failed to copy to clipboard: {}", e))?;
}
Err(_) => return Err(Error::msg("Failed to fork()")),
Ok(_) => {}
}
Ok(())
}
#[cfg(not(target_os = "linux"))]
fn copy_to_clipbpard(decrypted: String) -> Result<(), Error> {
let mut ctx: ClipboardContext = ClipboardProvider::new()
.map_err(|e| anyhow!("failed to initialize clipboard provider: {}", e))?;
ctx.set_contents(decrypted)
.map_err(|e| anyhow!("failed to copy to clipboard: {}", e))?;
Ok(())
}
fn show(entry: &str, on_screen: bool, no_keyring: bool) -> Result<()> {
run_hook(&Hook::PreLoad, &HookEvent::ShowEntry)?;
let passphrase = get_passphrase("Enter passphrase: ", no_keyring)?;
let storage = load_entries(&passphrase)?;
if storage.entries.contains_key(entry) {
let password = &storage
.entries
.get(entry)
.ok_or_else(|| anyhow!("entry {} not found", entry))?
.password;
if on_screen {
println!("{}", password);
} else {
copy_to_clipbpard(password.to_string())?;
}
} else {
return Err(anyhow!("{} not found", entry));
}
Ok(())
}
fn edit(entry: &str, no_keyring: bool) -> Result<()> {
run_hook(&Hook::PreLoad, &HookEvent::ShowEntry)?;
let passphrase = get_passphrase("Enter passphrase: ", no_keyring)?;
let mut storage = load_entries(&passphrase)?;
if storage.entries.contains_key(entry) {
let password = rpassword::prompt_password_stdout(&format!("New password for {}: ", entry))?;
storage.entries.insert(entry.to_owned(), Entry { password });
save_entries(passphrase, &storage)?;
run_hook(&Hook::PostSave, &HookEvent::EditEntry)?;
} else {
return Err(anyhow!("entry not found: {}", entry));
};
Ok(())
}
fn remove(entry: &str, no_keyring: bool) -> Result<()> {
run_hook(&Hook::PreLoad, &HookEvent::ShowEntry)?;
let passphrase = get_passphrase("Enter passphrase: ", no_keyring)?;
let mut storage = load_entries(&passphrase)?;
if storage.entries.remove(entry).is_some() {
save_entries(passphrase, &storage)?;
run_hook(&Hook::PostSave, &HookEvent::RemoveEntry)?;
} else {
return Err(anyhow!("entry not found: {}", entry));
};
Ok(())
}
fn info() -> Result<()> {
let storage_path = entries_file()?;
if fs::metadata(storage_path.clone()).is_ok() {
println!("Storage file: {}", storage_path);
} else {
println!("Storage file doesn't exist yet, run `passage init` to create it");
}
let hooks_dir = hooks_dir()?;
if fs::metadata(&hooks_dir).is_ok() {
println!("Hooks directory: {}", hooks_dir);
} else {
println!("Hooks directory does not exist yet: {}", hooks_dir);
}
Ok(())
}
fn run_hook(hook: &Hook, event: &HookEvent) -> Result<()> {
let path = Path::new(&hooks_dir()?)
.join(hook.name())
.display()
.to_string();
if fs::metadata(&path).is_ok() {
println!("Running {} hook", hook.name());
let storage_dir = storage_dir()?;
let output = Command::new(path)
.args(&[event.name()])
.current_dir(storage_dir)
.output()?;
let stdout = String::from_utf8(output.stdout)?;
let stderr = String::from_utf8(output.stderr)?;
for line in stdout.lines() {
println!("{}: {}", hook.name(), line);
}
for line in stderr.lines() {
println!("{}: {}", hook.name(), line);
}
if !output.status.success() {
anyhow!("{} hook failed", hook.name());
}
}
Ok(())
}
fn keyring_check() -> Result<()> {
let username = &whoami::username();
let keyring = keyring::Keyring::new(KEYRING_APP_NAME, username);
if keyring.get_password().is_err() {
anyhow!("Failed to access password in keyring");
}
println!("Keyring integration seems fine");
Ok(())
}
fn keyring_forget() -> Result<()> {
let username = &whoami::username();
let keyring = keyring::Keyring::new(KEYRING_APP_NAME, username);
if keyring.delete_password().is_err() {
anyhow!("Failed to delete password from keyring");
}
Ok(())
}
fn main() -> Result<(), Error> {
let opt = Opt::from_args();
match opt.cmd {
Cmd::New => new_entry(opt.no_keyring),
Cmd::List => list(opt.no_keyring),
Cmd::Init => init(opt.no_keyring),
Cmd::Show { entry, on_screen } => show(&entry, on_screen, opt.no_keyring),
Cmd::Edit { entry } => edit(&entry, opt.no_keyring),
Cmd::Remove { entry } => remove(&entry, opt.no_keyring),
Cmd::Info => info(),
Cmd::Keyring(ko) => match ko {
KeyringOpt::Check => keyring_check(),
KeyringOpt::Forget => keyring_forget(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ok() {
let text = b"this is plain";
let passphrase = Secret::new("secret".to_string());
let encrypted = encrypt(text, passphrase.clone()).unwrap();
let decrypted = decrypt(&encrypted, &passphrase).unwrap();
assert_eq!(decrypted, text);
}
#[test]
fn test_entry_serialization() {
let s: Storage = toml::from_str("[foo] \n password = 'bar'").unwrap();
assert_eq!(s.entries.get("foo").unwrap().password, "bar");
}
}