mod broker;
mod crypto;
#[cfg(feature = "host")]
mod import;
mod mcp;
#[cfg(feature = "host")]
mod plugin;
#[cfg(feature = "host")]
mod project;
#[cfg(feature = "host")]
mod se;
mod store;
#[cfg(feature = "host")]
mod sync;
#[cfg(feature = "host")]
mod tailnet;
mod tree;
#[cfg(feature = "host")]
mod yubikey;
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
use std::alloc::{GlobalAlloc, Layout, System};
use zeroize::Zeroize;
struct WipeOnFree;
unsafe impl GlobalAlloc for WipeOnFree {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe {
std::slice::from_raw_parts_mut(ptr, layout.size()).zeroize();
System.dealloc(ptr, layout)
}
}
}
#[global_allocator]
static ALLOCATOR: WipeOnFree = WipeOnFree;
use secrecy::SecretString;
use std::io::{IsTerminal, Read, Write};
use store::{DEFAULT_WINDOW_SECS, Mode, Secret, Store};
const PASSPHRASE_ENV: &str = "PASSBOX_PASSPHRASE";
const MIN_PASSPHRASE: usize = 12;
#[derive(Parser)]
#[command(
name = "passbox",
version,
about = "An age password store that asks before an agent reads it"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Init,
Add {
name: String,
#[arg(long)]
mode: Option<Mode>,
#[arg(long)]
window: Option<u64>,
},
Get {
name: String,
#[arg(long)]
field: Option<String>,
},
Grant {
#[arg(required = true)]
names: Vec<String>,
#[arg(long, default_value_t = 3600)]
r#for: u64,
},
Ls,
Rm { name: String },
Mode {
name: String,
mode: Mode,
#[arg(long)]
window: Option<u64>,
},
Restore {
name: String,
#[arg(long)]
index: Option<usize>,
},
#[cfg(feature = "host")]
MachineAdd,
#[cfg(feature = "host")]
YubikeyAdd {
recipient: String,
},
Exec {
#[arg(long = "env", value_name = "VAR=SECRET")]
envs: Vec<String>,
#[arg(long)]
stdin: Option<String>,
#[arg(trailing_var_arg = true, required = true)]
command: Vec<String>,
},
#[cfg(feature = "host")]
Audit {
#[arg(long, default_value_t = 20)]
tail: usize,
},
#[cfg(feature = "host")]
Sync {
#[arg(long)]
remote: Option<String>,
#[arg(long)]
enable: bool,
},
#[cfg(feature = "host")]
ImportPass {
prefix: Option<String>,
#[arg(long)]
mode: Option<Mode>,
#[arg(long)]
force: bool,
},
#[cfg(feature = "host")]
Plugin {
#[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
args: Vec<String>,
},
Mcp,
Git {
#[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
args: Vec<String>,
},
#[cfg(feature = "host")]
#[command(hide = true)]
Broker,
}
fn main() {
if let Err(e) = run() {
eprintln!("passbox: {e:#}");
std::process::exit(1);
}
}
fn run() -> Result<()> {
let cli = Cli::parse();
let store = Store::open()?;
store.remove_name_index()?;
match cli.command {
Command::Init => init(&store),
Command::Add { name, mode, window } => {
add(&store, &name, mode, window).inspect(|()| written(&store, "add a secret"))
}
Command::Get { name, field } => get(&store, &name, field.as_deref()),
Command::Grant { names, r#for } => grant(&store, &names, r#for),
Command::Ls => ls(&store),
Command::Rm { name } => rm(&store, &name).inspect(|()| written(&store, "remove a secret")),
Command::Mode { name, mode, window } => set_mode(&store, &name, mode, window)
.inspect(|()| written(&store, "change a permission mode")),
Command::Restore { name, index } => restore(&store, &name, index)
.inspect(|()| written(&store, "restore an earlier version")),
#[cfg(feature = "host")]
Command::MachineAdd => machine_add(&store),
#[cfg(feature = "host")]
Command::YubikeyAdd { recipient } => yubikey_add(&store, &recipient)
.inspect(|()| written(&store, "wrap the store key to a YubiKey")),
Command::Exec {
envs,
stdin,
command,
} => exec(&store, &envs, stdin.as_deref(), &command),
#[cfg(feature = "host")]
Command::Audit { tail } => audit(&store, tail),
#[cfg(feature = "host")]
Command::Sync { remote, enable } => run_sync(&store, remote.as_deref(), enable),
#[cfg(feature = "host")]
Command::ImportPass {
prefix,
mode,
force,
} => import_pass(&store, prefix.as_deref(), mode, force)
.inspect(|()| written(&store, "import from pass")),
#[cfg(feature = "host")]
Command::Plugin { args } => plugin_cmd(&store, &args),
Command::Mcp => mcp::serve(store),
Command::Git { args } => git(&store, &args),
#[cfg(feature = "host")]
Command::Broker => broker::serve(store),
}
}
fn ensure_store_gitignore(store: &Store) -> Result<()> {
let path = store.dir.join(".gitignore");
if !path.exists() {
std::fs::write(
&path,
"broker.sock\nnames\nremote\nstore/.versions/\nwraps/se-*.json\ngrants-*.age\n",
)?;
}
Ok(())
}
fn git(store: &Store, args: &[String]) -> Result<()> {
ensure_store_gitignore(store)?;
let status = std::process::Command::new("git")
.arg("-C")
.arg(&store.dir)
.args(args)
.status()
.context("git is not on PATH")?;
std::process::exit(status.code().unwrap_or(1));
}
#[cfg(feature = "host")]
fn run_sync(store: &Store, remote: Option<&str>, enable: bool) -> Result<()> {
if enable {
enable_sync(store)?;
} else if store.is_initialised() && !store.has_portable_wrap() {
bail!(
"sync is off. No other machine could open this store, because the key is held by \
this Mac's Secure Enclave alone. Add a way in first: `passbox sync --enable` for \
a recovery passphrase, or `passbox yubikey-add` for a token."
);
}
let remote = match remote {
Some(given) => given.to_string(),
None => sync::default_remote(store)?,
};
match sync::classify(&remote) {
sync::Kind::Git => git_push(store),
sync::Kind::Rclone => {
sync::sync_via_rclone(store, &remote)?;
git_push(store);
}
sync::Kind::Directory => {
let report = sync::sync(store, std::path::Path::new(&remote))?;
eprintln!(
"{} sent, {} received, {remote}",
report.pushed, report.pulled
);
git_push(store);
}
}
Ok(())
}
fn written(store: &Store, what: &str) {
commit(store, what);
mirror(store);
}
fn in_git(store: &Store) -> bool {
store.dir.join(".git").exists()
}
fn git_quietly(store: &Store, args: &[&str]) {
let _ = std::process::Command::new("git")
.arg("-C")
.arg(&store.dir)
.args(args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
fn commit(store: &Store, what: &str) {
if !in_git(store) {
return;
}
let _ = ensure_store_gitignore(store);
git_quietly(store, &["add", "-A"]);
git_quietly(store, &["commit", "-m", what]);
}
#[cfg(feature = "host")]
fn mirror(store: &Store) {
if std::env::var_os("PASSBOX_NO_AUTOSYNC").is_some() {
return;
}
let Some(remote) = sync::configured_remote(store) else {
return;
};
if sync::classify(&remote) != sync::Kind::Directory {
return;
}
match sync::sync(store, std::path::Path::new(&remote)) {
Ok(report) if report.pushed > 0 => eprintln!("{} copied to {remote}", report.pushed),
Ok(_) => {}
Err(e) => eprintln!("not copied to {remote}: {e:#}"),
}
}
#[cfg(not(feature = "host"))]
fn mirror(_store: &Store) {}
#[cfg(feature = "host")]
fn git_push(store: &Store) {
if !in_git(store) {
return;
}
commit(store, "update");
git_quietly(store, &["push"]);
}
fn agent() -> String {
std::env::var("PASSBOX_AGENT").unwrap_or_else(|_| "passbox".to_string())
}
fn read_secret(store: &Store, name: &str, for_agent: bool) -> Result<String> {
read_secret_as(store, name, &agent(), for_agent)
}
pub fn agent_read_secret(store: &Store, name: &str, agent: &str) -> Result<String> {
read_secret_as(store, name, agent, true)
}
fn read_secret_as(store: &Store, name: &str, agent: &str, for_agent: bool) -> Result<String> {
if store.host().is_some() || (!headless() && store.has_se_wrap()) {
return broker::request(store, name, agent);
}
let key = unlock(store, &format!("release the password for {name}"))?;
let (_, secret) = store
.find(name, &key)?
.with_context(|| format!("no secret named {name}"))?;
if for_agent && secret.mode == Mode::Never {
bail!("{name} is marked never, refusing");
}
Ok(secret.value.clone())
}
fn exec(store: &Store, envs: &[String], stdin: Option<&str>, command: &[String]) -> Result<()> {
let (program, args) = command.split_first().context("no command to run")?;
let mut child = std::process::Command::new(program);
child.args(args);
for pair in envs {
let (var, name) = pair
.split_once('=')
.with_context(|| format!("expected VAR=secret, got {pair}"))?;
child.env(var, read_secret(store, name, true)?);
}
let piped = match stdin {
Some(name) => Some(read_secret(store, name, true)?),
None => None,
};
child.stdin(if piped.is_some() {
std::process::Stdio::piped()
} else {
std::process::Stdio::inherit()
});
let mut running = child
.spawn()
.with_context(|| format!("could not run {program}"))?;
if let Some(value) = piped {
running
.stdin
.as_mut()
.expect("piped")
.write_all(value.as_bytes())?;
drop(running.stdin.take());
}
let status = running.wait()?;
std::process::exit(status.code().unwrap_or(1));
}
#[cfg(feature = "host")]
fn audit(store: &Store, tail: usize) -> Result<()> {
let path = store.audit_path();
if !path.exists() {
eprintln!("nothing released on this machine yet");
return Ok(());
}
let key = unlock(store, "read the audit log")?;
let text = std::fs::read_to_string(&path)?;
let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
for line in lines.iter().skip(lines.len().saturating_sub(tail)) {
match crypto::decrypt_line(line, &key) {
Ok(record) => println!("{}", String::from_utf8_lossy(&record)),
Err(e) => eprintln!("unreadable record: {e:#}"),
}
}
Ok(())
}
fn init(store: &Store) -> Result<()> {
let key = store.init()?;
eprintln!("store ready at {}", store.dir.display());
#[cfg(not(feature = "host"))]
let bound = false;
#[cfg(feature = "host")]
let bound = bind_machine(store, &key);
if headless() || !bound {
new_passphrase(store, &key)?;
} else {
eprintln!();
eprintln!("This store opens on this Mac only. Lose it and the secrets are gone.");
eprintln!("`passbox sync --enable` adds a passphrase and a copy on another machine.");
}
Ok(())
}
#[cfg(feature = "host")]
fn enable_sync(store: &Store) -> Result<()> {
if store.has_portable_wrap() {
eprintln!("the store already has a wrap that opens it elsewhere");
return choose_remote(store);
}
eprintln!("Syncing puts a copy of this store where another machine can read it.");
eprintln!("That copy needs a way in. Two choices:");
eprintln!();
eprintln!(" 1) A YubiKey. Nothing in the copy can be attacked, and you keep the token.");
eprintln!(" 2) A passphrase. Works anywhere, and anyone holding the copy can grind it.");
eprintln!();
eprint!("Which? [1/2] ");
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
match answer.trim() {
"1" => enable_with_yubikey(store)?,
"2" => {
let key = unlock(store, "turn on sync")?;
new_passphrase(store, &key)?;
eprintln!("sync is on, wraps/recovery.age now travels with the store");
}
other => bail!("expected 1 or 2, got {other}"),
}
choose_remote(store)
}
#[cfg(feature = "host")]
fn choose_remote(store: &Store) -> Result<()> {
if let Some(already) = sync::configured_remote(store) {
eprintln!("copies currently go to {already}");
eprint!("Change that? [y/N] ");
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") {
return Ok(());
}
std::fs::remove_file(sync::remote_path(store)).ok();
}
eprintln!();
eprintln!("Where should the copy go?");
eprintln!();
eprintln!(" 1) iCloud Drive, a folder on this Mac that Apple replicates");
eprintln!(" 2) A directory you name, such as a USB stick or Dropbox");
eprintln!(" 3) A git remote, which also gives you history");
eprintln!(" 4) An rclone remote, for S3, B2, Drive and the rest");
eprintln!();
eprint!("Which? [1/2/3/4] ");
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
let remote = match answer.trim() {
"1" => sync::default_remote(store)?,
"2" => prompt("directory: ")?,
"3" => {
let url = prompt("git remote, such as git@github.com:you/passbox-store.git: ")?;
git(store, &["init".into()]).ok();
git(
store,
&["remote".into(), "add".into(), "origin".into(), url.clone()],
)
.ok();
url
}
"4" => prompt("rclone remote, such as b2:passbox: ")?,
other => bail!("expected 1 to 4, got {other}"),
};
store::write_private(&sync::remote_path(store), remote.trim().as_bytes())?;
eprintln!("copies go to {}", remote.trim());
Ok(())
}
#[cfg(feature = "host")]
fn prompt(label: &str) -> Result<String> {
eprint!("{label}");
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
let answer = answer.trim().to_string();
if answer.is_empty() {
bail!("nothing given");
}
Ok(answer)
}
#[cfg(feature = "host")]
fn enable_with_yubikey(store: &Store) -> Result<()> {
yubikey::offer_install()?;
let mut found = yubikey::recipients()?;
if found.is_empty() {
eprintln!("No age identity on the token yet. Generating one uses a PIV slot.");
match yubikey::piv_slots_in_use() {
Some(slots) if !slots.is_empty() => {
eprintln!("The PIV applet already holds:");
for slot in &slots {
eprintln!(" {slot}");
}
eprintln!("A free slot is used, and your OpenPGP keys are a separate applet.");
}
Some(_) => eprintln!("The PIV applet is empty, and OpenPGP is a separate applet."),
None => eprintln!("Could not read the PIV applet. Install ykman to check it first."),
}
yubikey::management_key_is_supported()?;
eprint!("Generate one now? [y/N] ");
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") {
bail!("run `age-plugin-yubikey --generate` when ready, then try again");
}
yubikey::offer_to_stop_scdaemon()?;
yubikey::generate()?;
found = yubikey::recipients()?;
}
let recipient = found
.first()
.context("no YubiKey recipient found, is the token plugged in?")?;
eprintln!("using {recipient}");
let key = unlock(store, "turn on sync with a YubiKey")?;
let total = store.add_yubikey_recipient(&key, recipient)?;
eprintln!("sync is on, and the copy is useless without one of those {total} token(s)");
eprintln!("add a second with `passbox yubikey-add`, or losing it loses the way back");
Ok(())
}
fn new_passphrase(store: &Store, key: &age::x25519::Identity) -> Result<()> {
let pass = ask("recovery passphrase: ")?;
if pass.chars().count() < MIN_PASSPHRASE {
bail!("use at least {MIN_PASSPHRASE} characters, a copy of this leaves the Mac");
}
if !headless() && ask("again: ")? != pass {
bail!("passphrases do not match");
}
store.create_recovery_wrap_with_factor(key, SecretString::from(pass), test_work_factor())
}
fn test_work_factor() -> Option<u8> {
#[cfg(debug_assertions)]
{
std::env::var("PASSBOX_SCRYPT_LOG_N")
.ok()
.and_then(|v| v.parse().ok())
}
#[cfg(not(debug_assertions))]
{
None
}
}
#[cfg(feature = "host")]
fn bind_machine(store: &Store, key: &age::x25519::Identity) -> bool {
if headless() {
return false;
}
match store.create_se_wrap(key) {
Ok(()) => {
eprintln!("bound to this Mac, reads will ask for your fingerprint");
true
}
Err(e) => {
eprintln!("no Secure Enclave here ({e:#}), reads will ask for the passphrase");
false
}
}
}
fn headless() -> bool {
std::env::var_os(PASSPHRASE_ENV).is_some()
}
fn unlock(store: &Store, reason: &str) -> Result<age::x25519::Identity> {
#[cfg(feature = "host")]
if !headless() && store.has_se_wrap() {
return store.unlock_with_se(reason);
}
let _ = reason;
store.unlock_with_passphrase(SecretString::from(ask("passphrase: ")?))
}
#[cfg(feature = "host")]
fn yubikey_add(store: &Store, recipient: &str) -> Result<()> {
yubikey::offer_install()?;
let key = unlock(store, "wrap the store key to a YubiKey")?;
let total = store.add_yubikey_recipient(&key, recipient)?;
eprintln!("wrapped to {recipient}");
eprintln!("{total} token(s) can now open this store on any machine");
if total == 1 {
eprintln!("add a second with `passbox yubikey-add`, or losing it loses the way back");
}
Ok(())
}
#[cfg(feature = "host")]
fn machine_add(store: &Store) -> Result<()> {
if store.has_se_wrap() {
bail!(
"this Mac is already bound, its wrap is {}",
store.se_wrap_path().display()
);
}
let key = if store.has_yubikey_wrap() && yubikey::installed() {
eprintln!("Touch the YubiKey to bind this Mac.");
store.unlock_with_yubikey()?
} else {
eprintln!("Binding this Mac needs the recovery passphrase once.");
store.unlock_with_passphrase(SecretString::from(ask("recovery passphrase: ")?))?
};
store.create_se_wrap(&key)?;
eprintln!("bound, reads on this Mac will ask for your fingerprint");
Ok(())
}
fn ask(prompt: &str) -> Result<String> {
match std::env::var(PASSPHRASE_ENV) {
Ok(pass) => Ok(pass),
Err(_) => Ok(rpassword::prompt_password(prompt)?),
}
}
fn add(store: &Store, name: &str, mode: Option<Mode>, window: Option<u64>) -> Result<()> {
let recipient = store.recipient()?;
let value = read_value()?;
if value.is_empty() {
bail!("refusing to store an empty value");
}
let existing = if store.ids()?.is_empty() {
None
} else {
let key = unlock(store, &format!("replace {name}"))?;
store
.find(name, &key)?
.map(|(id, s)| (id, s.created, s.mode, s.window_secs))
};
let secret = match existing {
Some((id, created, old_mode, old_window)) => (
id,
Secret {
name: name.to_string(),
mode: mode.unwrap_or(old_mode),
window_secs: window.unwrap_or(old_window),
value,
created,
updated: store::now(),
},
),
None => (
store::new_id(),
Secret {
name: name.to_string(),
mode: mode.unwrap_or(Mode::Window),
window_secs: window.unwrap_or(DEFAULT_WINDOW_SECS),
value,
created: store::now(),
updated: store::now(),
},
),
};
let (id, secret) = secret;
store.put(&id, &secret, &recipient)?;
eprintln!("stored {name} as {}", secret.mode);
Ok(())
}
#[cfg(feature = "host")]
fn import_pass(store: &Store, prefix: Option<&str>, mode: Option<Mode>, force: bool) -> Result<()> {
let dir = import::store_dir();
let all = import::entries(&dir)?;
if all.is_empty() {
bail!("no pass entries under {}", dir.display());
}
let picked = import::select(&all, prefix);
if picked.is_empty() {
bail!(
"nothing in pass matches {}, of {} entries",
prefix.unwrap_or("*"),
all.len()
);
}
eprintln!("importing {} of {} entries", picked.len(), all.len());
let key = if store.ids()?.is_empty() {
None
} else {
Some(unlock(store, "import entries from pass")?)
};
let report = import::import(
store,
&picked,
key.as_ref(),
mode.unwrap_or(Mode::Window),
force,
)?;
eprintln!("imported {}, skipped {}", report.imported, report.skipped);
Ok(())
}
fn fields(value: &str) -> std::collections::HashMap<String, String> {
let mut out = std::collections::HashMap::new();
let mut lines = value.lines();
if let Some(first) = lines.next() {
out.insert("password".to_string(), first.to_string());
}
for line in lines {
if let Some((key, v)) = line.split_once(':') {
let (key, v) = (key.trim().to_lowercase(), v.trim());
if !key.is_empty() && !v.is_empty() {
out.insert(key, v.to_string());
}
}
}
out
}
fn get(store: &Store, name: &str, field: Option<&str>) -> Result<()> {
let whole = read_secret(store, name, false)?;
let value = match field {
None => whole,
Some(want) => fields(&whole)
.remove(want)
.with_context(|| format!("{name} has no field {want}"))?,
};
let mut out = std::io::stdout();
out.write_all(value.as_bytes())?;
if out.is_terminal() {
out.write_all(b"\n")?;
}
Ok(())
}
pub fn agent_list_names(store: &Store, agent: &str) -> Result<Vec<String>> {
if store.host().is_some() || (!headless() && store.has_se_wrap()) {
return broker::list(store, agent);
}
let key = unlock(store, "list your secret names")?;
store.names(&key)
}
#[cfg(feature = "host")]
fn plugin_cmd(store: &Store, args: &[String]) -> Result<()> {
let (head, rest) = args
.split_first()
.context("usage: passbox plugin <list|add|update|remove|NAME> ...")?;
match head.as_str() {
"list" => plugin_list(store),
"add" => plugin_install(store, rest, false),
"update" => plugin_install(store, rest, true),
"remove" => plugin_remove(store, rest),
name => plugin_run(store, name, rest),
}
}
#[cfg(feature = "host")]
fn plugin_list(store: &Store) -> Result<()> {
let plugins = plugin::installed(store)?;
if plugins.is_empty() {
println!("no plugins, add one with `passbox plugin add <dir>`");
return Ok(());
}
for manifest in plugins {
println!(
"{} {} {}{}",
manifest.name,
manifest.version(),
manifest.description,
stale_note(&manifest)
);
for tool in &manifest.tools {
println!(
" {} {} [{:?}] {}",
manifest.name, tool.name, tool.mode, tool.description
);
}
}
Ok(())
}
#[cfg(feature = "host")]
fn stale_note(manifest: &plugin::Manifest) -> String {
let Some(files) = first_party(&manifest.name) else {
return String::new();
};
let Some(bundled) = files
.iter()
.find(|(name, _)| *name == "plugin.toml")
.and_then(|(_, toml)| toml::from_str::<plugin::Manifest>(toml).ok())
else {
return String::new();
};
if bundled.version() != manifest.version() {
return format!(
" (update available: {} -> {}, run `passbox plugin update {}`)",
manifest.version(),
bundled.version(),
manifest.name
);
}
String::new()
}
#[cfg(feature = "host")]
fn plugin_install(store: &Store, rest: &[String], overwrite: bool) -> Result<()> {
let verb = if overwrite { "update" } else { "add" };
let arg = rest
.first()
.with_context(|| format!("usage: passbox plugin {verb} <name|dir>"))?;
let fresh = |to: &std::path::Path| -> Result<()> {
if to.exists() {
if !overwrite {
bail!("already installed, run `passbox plugin update` to refresh it");
}
std::fs::remove_dir_all(to)?;
}
Ok(())
};
if let Some(files) = first_party(arg) {
let to = store.plugins_dir().join(arg);
fresh(&to)?;
std::fs::create_dir_all(&to)?;
for (name, contents) in files {
std::fs::write(to.join(name), contents)?;
}
eprintln!("{}ed {arg}", if overwrite { "updat" } else { "install" });
return Ok(());
}
let from = std::path::Path::new(arg);
let text = std::fs::read_to_string(from.join("plugin.toml"))
.with_context(|| format!("no first-party plugin {arg}, and no plugin.toml in {arg}"))?;
let manifest: plugin::Manifest = toml::from_str(&text).context("plugin.toml")?;
let to = store.plugins_dir().join(&manifest.name);
fresh(&to)?;
copy_dir(from, &to)?;
eprintln!(
"{}ed {} {} with {} tool(s)",
if overwrite { "updat" } else { "install" },
manifest.name,
manifest.version(),
manifest.tools.len()
);
Ok(())
}
#[cfg(feature = "host")]
fn first_party(name: &str) -> Option<&'static [(&'static str, &'static str)]> {
match name {
"mail" => Some(&[
("plugin.toml", include_str!("../plugins/mail/plugin.toml")),
(
"recent.applescript",
include_str!("../plugins/mail/recent.applescript"),
),
(
"search.applescript",
include_str!("../plugins/mail/search.applescript"),
),
(
"attachments.applescript",
include_str!("../plugins/mail/attachments.applescript"),
),
(
"send.applescript",
include_str!("../plugins/mail/send.applescript"),
),
]),
"calendar" => Some(&[
(
"plugin.toml",
include_str!("../plugins/calendar/plugin.toml"),
),
("agenda.sh", include_str!("../plugins/calendar/agenda.sh")),
]),
"things3" => Some(&[(
"plugin.toml",
include_str!("../plugins/things3/plugin.toml"),
)]),
"git" => Some(&[("plugin.toml", include_str!("../plugins/git/plugin.toml"))]),
"gh" => Some(&[("plugin.toml", include_str!("../plugins/gh/plugin.toml"))]),
_ => None,
}
}
#[cfg(feature = "host")]
fn plugin_remove(store: &Store, rest: &[String]) -> Result<()> {
let name = rest
.first()
.context("usage: passbox plugin remove <name>")?;
plugin::valid_name(name)?;
let dir = store.plugins_dir().join(name);
if !dir.exists() {
bail!("no plugin named {name}");
}
std::fs::remove_dir_all(&dir)?;
eprintln!("removed {name}");
Ok(())
}
#[cfg(feature = "host")]
fn plugin_run(store: &Store, name: &str, rest: &[String]) -> Result<()> {
let (action, params) = rest
.split_first()
.context("usage: passbox plugin <name> <action> [--key value]")?;
let manifest = plugin::load(store, name)?;
let tool = manifest
.tool(action)
.with_context(|| format!("{name} has no action {action}"))?;
manifest.ensure_available()?;
let mut command = tool.command(&parse_params(params)?)?;
if !manifest.runs_in_caller_dir() {
command.current_dir(store.plugins_dir().join(name));
}
if store.host().is_some() || (!headless() && store.has_se_wrap()) {
broker::plugin_approve(store, name, action, &agent())?;
} else {
match tool.mode {
Mode::Never => bail!("{name} {action} is marked never, refusing"),
Mode::Open => {}
Mode::Window | Mode::Always => {
unlock(store, &format!("run {name} {action}"))?;
}
}
}
let status = command
.status()
.with_context(|| format!("could not run {name} {action}"))?;
std::process::exit(status.code().unwrap_or(1));
}
#[cfg(feature = "host")]
fn parse_params(rest: &[String]) -> Result<std::collections::HashMap<String, String>> {
let mut out = std::collections::HashMap::new();
let mut tokens = rest.iter();
while let Some(token) = tokens.next() {
let key = token
.strip_prefix("--")
.with_context(|| format!("expected --key, got {token}"))?;
if let Some((k, v)) = key.split_once('=') {
out.insert(k.to_string(), v.to_string());
} else {
let value = tokens
.next()
.with_context(|| format!("--{key} needs a value"))?;
out.insert(key.to_string(), value.clone());
}
}
Ok(out)
}
#[cfg(feature = "host")]
fn copy_dir(from: &std::path::Path, to: &std::path::Path) -> Result<()> {
std::fs::create_dir_all(to)?;
for entry in std::fs::read_dir(from)? {
let entry = entry?;
let target = to.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir(&entry.path(), &target)?;
} else {
std::fs::copy(entry.path(), target)?;
}
}
Ok(())
}
pub fn run_child(
command: &[String],
env_var: Option<&str>,
value: &str,
) -> Result<std::process::Output> {
let (program, args) = command.split_first().context("no command to run")?;
let mut child = std::process::Command::new(program);
child
.args(args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
match env_var {
Some(var) => {
child.env(var, value);
child.stdin(std::process::Stdio::null());
}
None => {
child.stdin(std::process::Stdio::piped());
}
}
let mut running = child
.spawn()
.with_context(|| format!("could not run {program}"))?;
if env_var.is_none() {
running
.stdin
.as_mut()
.expect("piped")
.write_all(value.as_bytes())?;
drop(running.stdin.take());
}
Ok(running.wait_with_output()?)
}
fn grant(store: &Store, names: &[String], ttl: u64) -> Result<()> {
if headless() || !store.has_se_wrap() {
bail!("a token needs the broker, which this store does not use");
}
let answer = broker::grant(store, names, &agent(), ttl)?;
let mut lines = answer.lines();
let token = lines.next().context("the broker returned no token")?;
let covered: Vec<&str> = lines.collect();
println!("{token}");
eprintln!("opens {} secret(s) for {ttl}s:", covered.len());
for name in &covered {
eprintln!(" {name}");
}
eprintln!();
eprintln!("pass it back with PASSBOX_TOKEN, and it stops working when it lapses:");
eprintln!(" export PASSBOX_TOKEN={token}");
Ok(())
}
fn ls(store: &Store) -> Result<()> {
let names = agent_list_names(store, &agent())?;
let mut out = std::io::stdout();
if out.is_terminal() {
out.write_all(tree::render(&names, true).as_bytes())?;
} else {
for name in names {
writeln!(out, "{name}")?;
}
}
Ok(())
}
fn rm(store: &Store, name: &str) -> Result<()> {
let key = unlock(store, &format!("delete {name}"))?;
let (id, _) = store
.find(name, &key)?
.with_context(|| format!("no secret named {name}"))?;
store.delete(&id)?;
store.prune_tombs()?;
eprintln!("deleted {name}, recoverable with `passbox restore {name}`");
Ok(())
}
fn set_mode(store: &Store, name: &str, mode: Mode, window: Option<u64>) -> Result<()> {
let key = unlock(store, &format!("change the permission mode of {name}"))?;
let recipient = store.recipient()?;
let matched = store::select(&store.names(&key)?, Some(name));
if matched.is_empty() {
bail!("no secret named {name}, and nothing under {name}/");
}
for each in &matched {
let (id, mut secret) = store
.find(each, &key)?
.with_context(|| format!("no secret named {each}"))?;
secret.mode = mode;
if let Some(w) = window {
secret.window_secs = w;
}
secret.updated = store::now();
store.put(&id, &secret, &recipient)?;
eprintln!("{each} is now {mode}");
}
Ok(())
}
fn restore(store: &Store, name: &str, index: Option<usize>) -> Result<()> {
let key = unlock(store, &format!("restore {name}"))?;
let id = match store.find(name, &key)? {
Some((id, _)) => id,
None => find_deleted(store, name, &key)?,
};
if store.tomb_path(&id).exists() && store.secret_path(&id).exists() {
store.untomb(&id)?;
eprintln!("restored {name}");
return Ok(());
}
let mut versions = store.versions(&id)?;
versions.reverse();
if versions.is_empty() {
bail!("no earlier versions of {name}");
}
let Some(index) = index else {
for (i, (stamp, path)) in versions.iter().enumerate() {
let bytes = store
.load_path(path, &key)
.map(|s| s.value.len())
.unwrap_or(0);
println!("{i}\t{stamp}\t{bytes} bytes");
}
eprintln!("restore one with `passbox restore {name} --index N`");
return Ok(());
};
let (_, path) = versions
.get(index)
.with_context(|| format!("no version {index}"))?;
let bytes = std::fs::read(path)?;
store.keep_version(&id)?;
store::write_private(&store.secret_path(&id), &bytes)?;
let tomb = store.tomb_path(&id);
if tomb.exists() {
std::fs::remove_file(tomb)?;
}
eprintln!("restored {name}");
Ok(())
}
fn find_deleted(store: &Store, name: &str, key: &age::x25519::Identity) -> Result<String> {
for id in store.ids_with_files()? {
if let Ok(secret) = store.load(&id, key)
&& secret.name == name
{
return Ok(id);
}
}
let dir = store.versions_dir();
if dir.exists() {
for entry in std::fs::read_dir(&dir)? {
let path = entry?.path();
let Some(id) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
for (_, version) in store.versions(id)? {
if let Ok(secret) = store.load_path(&version, key)
&& secret.name == name
{
return Ok(id.to_string());
}
}
}
}
bail!("no secret named {name}")
}
fn read_value() -> Result<String> {
if std::io::stdin().is_terminal() {
let value = rpassword::prompt_password("value: ")?;
if rpassword::prompt_password("again: ")? != value {
bail!("values do not match");
}
return Ok(value);
}
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
Ok(buf.trim_end_matches('\n').to_string())
}
#[cfg(test)]
mod tests {
use super::fields;
#[test]
fn freed_memory_is_wiped() {
use std::alloc::{Layout, alloc, dealloc};
let layout = Layout::from_size_align(4099, 1).unwrap();
unsafe {
let p = alloc(layout);
std::slice::from_raw_parts_mut(p, 4099).fill(0xAB);
dealloc(p, layout);
let q = alloc(layout);
let seen = std::slice::from_raw_parts(q, 4099);
assert!(seen.iter().all(|&b| b == 0), "freed bytes were not wiped");
dealloc(q, layout);
}
}
#[test]
fn the_first_line_is_the_password() {
let f = fields("s3cret\nusername: bot\nhost: example.com");
assert_eq!(f["password"], "s3cret");
assert_eq!(f["username"], "bot");
assert_eq!(f["host"], "example.com");
}
#[test]
fn a_value_holding_a_colon_survives() {
let f = fields("pw\nurl: https://example.com:8443/x");
assert_eq!(f["url"], "https://example.com:8443/x");
}
#[test]
fn a_single_line_secret_has_only_a_password() {
let f = fields("just-a-token");
assert_eq!(f["password"], "just-a-token");
assert_eq!(f.len(), 1);
}
}