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::{Secret, Store};
const PASSPHRASE_ENV: &str = "PASSBOX_PASSPHRASE";
const MIN_PASSPHRASE: usize = 12;
pub fn without_credentials(command: &mut std::process::Command) -> &mut std::process::Command {
command
.env_remove("PASSBOX_TOKEN")
.env_remove(PASSPHRASE_ENV)
}
struct Held {
token: Option<zeroize::Zeroizing<String>>,
passphrase: Option<zeroize::Zeroizing<String>>,
}
static HELD: std::sync::OnceLock<Held> = std::sync::OnceLock::new();
fn take_credentials() {
let _ = HELD.set(Held {
token: take_env("PASSBOX_TOKEN"),
passphrase: take_env(PASSPHRASE_ENV),
});
}
fn take_env(name: &str) -> Option<zeroize::Zeroizing<String>> {
let value = zeroize::Zeroizing::new(std::env::var(name).ok()?);
wipe_env_value(name);
unsafe { std::env::remove_var(name) };
Some(value)
}
fn wipe_env_value(name: &str) {
use zeroize::Zeroize;
#[cfg(target_os = "macos")]
let mut entry = unsafe { *libc::_NSGetEnviron() };
#[cfg(not(target_os = "macos"))]
let mut entry = unsafe { environ };
let prefix = format!("{name}=");
unsafe {
while !(*entry).is_null() {
let bytes = std::ffi::CStr::from_ptr(*entry).to_bytes();
if let Some(value) = bytes.strip_prefix(prefix.as_bytes()) {
let start = (*entry).add(prefix.len()).cast::<u8>();
std::slice::from_raw_parts_mut(start, value.len()).zeroize();
}
entry = entry.add(1);
}
}
}
#[cfg(not(target_os = "macos"))]
unsafe extern "C" {
static environ: *mut *mut libc::c_char;
}
pub fn held_token() -> Option<&'static str> {
HELD.get()?.token.as_deref().map(String::as_str)
}
fn held_passphrase() -> Option<&'static str> {
HELD.get()?.passphrase.as_deref().map(String::as_str)
}
#[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 },
Get {
name: String,
#[arg(long)]
field: Option<String>,
#[arg(long)]
reveal: bool,
},
Copy {
name: String,
#[arg(long)]
field: Option<String>,
#[arg(long, default_value_t = 20, value_parser = clap::value_parser!(u64).range(1..=60))]
clear: u64,
},
Grant {
#[arg(required = true)]
names: Vec<String>,
#[arg(long, default_value_t = 3600)]
r#for: u64,
},
Ls,
Rm { name: String },
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>,
},
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,
}
#[cfg(all(feature = "mock-enclave", not(debug_assertions)))]
compile_error!(
"mock-enclave swaps the Secure Enclave for a fake, it is for tests and never builds in release"
);
#[cfg(feature = "mock-enclave")]
fn refuse_outside_cargo() {
let inside = std::env::current_exe().ok().is_some_and(|exe| {
exe.ancestors()
.any(|dir| dir.join("CACHEDIR.TAG").is_file())
});
if !inside {
eprintln!("passbox: this mock-enclave test build is outside a cargo target directory");
std::process::abort();
}
}
fn main() {
#[cfg(feature = "mock-enclave")]
refuse_outside_cargo();
take_credentials();
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 } => add(&store, &name).inspect(|()| written(&store, "add a secret")),
Command::Get {
name,
field,
reveal,
} => get(&store, &name, field.as_deref(), reveal),
Command::Copy { name, field, clear } => copy(&store, &name, field.as_deref(), clear),
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::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 } => {
import_pass(&store, prefix.as_deref()).inspect(|()| written(&store, "import from pass"))
}
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_default()
}
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 brokered(store: &Store) -> bool {
store.host().is_some() || (!headless() && store.has_se_wrap())
}
fn read_secret_as(store: &Store, name: &str, agent: &str, for_agent: bool) -> Result<String> {
if brokered(store) {
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}"))?;
let _ = for_agent;
let caller = broker::ancestry(std::process::id() as libc::pid_t);
broker::append_audit(store, agent, name, "released", &caller)?;
Ok(secret.value.clone())
}
const EXEC_GRANT_SECS: u64 = 30;
fn batch_token(store: &Store, names: &[&str]) -> Option<String> {
let mut names: Vec<String> = names.iter().map(|n| n.to_string()).collect();
names.sort();
names.dedup();
if names.len() < 2 || !brokered(store) || held_token().is_some() {
return None;
}
let answer = broker::grant(store, &names, &agent(), EXEC_GRANT_SECS).ok()?;
answer.lines().next().map(str::to_string)
}
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);
without_credentials(&mut child).args(args);
let mut pairs = Vec::new();
for pair in envs {
let (var, name) = pair
.split_once('=')
.with_context(|| format!("expected VAR=secret, got {pair}"))?;
pairs.push((var, name));
}
let names: Vec<&str> = pairs.iter().map(|(_, name)| *name).chain(stdin).collect();
let token = batch_token(store, &names);
let read = |name: &str| match &token {
Some(token) => broker::request_token(store, name, &agent(), token),
None => read_secret(store, name, true),
};
for (var, name) in pairs {
child.env(var, read(name)?);
}
let piped = match stdin {
Some(name) => Some(read(name)?),
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 {
held_passphrase().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 held_passphrase() {
Some(pass) => Ok(pass.to_string()),
None => Ok(rpassword::prompt_password(prompt)?),
}
}
fn add(store: &Store, name: &str) -> Result<()> {
let recipient = store.recipient()?;
let value = read_value()?;
if value.is_empty() {
bail!("refusing to store an empty value");
}
let id = if store.ids_with_files()?.is_empty() && !store.versions_dir().exists() {
store::new_id()
} else {
let key = unlock(store, &format!("add {name}"))?;
if store.find(name, &key)?.is_some() {
bail!("{name} already exists, run `passbox rm {name}` first to replace it");
}
find_deleted(store, name, &key).unwrap_or_else(|_| store::new_id())
};
let secret = Secret {
name: name.to_string(),
value,
created: store::now(),
updated: store::now(),
};
store.put(&id, &secret, &recipient)?;
eprintln!("stored {name}");
Ok(())
}
#[cfg(feature = "host")]
fn import_pass(store: &Store, prefix: Option<&str>) -> 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())?;
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 read_field(store: &Store, name: &str, field: Option<&str>) -> Result<String> {
let whole = read_secret(store, name, false)?;
match field {
None => Ok(whole),
Some(want) => fields(&whole)
.remove(want)
.with_context(|| format!("{name} has no field {want}")),
}
}
fn get(store: &Store, name: &str, field: Option<&str>, reveal: bool) -> Result<()> {
let mut out = std::io::stdout();
if !reveal && !out.is_terminal() {
bail!(
"passbox get writes a secret to stdout and this is not a terminal\n\
use `passbox copy {name}`, `passbox exec --stdin {name} -- CMD`, the run_with_secret \
MCP tool, or `--reveal` to capture it anyway"
);
}
let value = read_field(store, name, field)?;
out.write_all(value.as_bytes())?;
if out.is_terminal() {
out.write_all(b"\n")?;
}
Ok(())
}
#[cfg(feature = "host")]
fn copy(store: &Store, name: &str, field: Option<&str>, clear: u64) -> Result<()> {
let value = read_field(store, name, field)?;
se::clip(&value, clear)?;
eprintln!("copied {name} to the clipboard, clears in {clear}s");
Ok(())
}
#[cfg(not(feature = "host"))]
fn copy(_store: &Store, _name: &str, _field: Option<&str>, _clear: u64) -> Result<()> {
bail!("copy needs the host build")
}
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)
}
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" | "add" | "update" | "remove" => plugin_manage(store, head, rest),
name => plugin_run(store, name, rest),
}
}
#[cfg(feature = "host")]
fn plugin_manage(store: &Store, verb: &str, rest: &[String]) -> Result<()> {
match verb {
"list" => plugin_list(store),
"add" => plugin_install(store, rest, false),
"update" => plugin_install(store, rest, true),
"remove" => plugin_remove(store, rest),
_ => unreachable!(),
}
}
#[cfg(not(feature = "host"))]
fn plugin_manage(_store: &Store, _verb: &str, _rest: &[String]) -> Result<()> {
bail!("manage plugins on the Mac that holds them, not on a client")
}
#[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.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(())
}
fn plugin_run(store: &Store, name: &str, rest: &[String]) -> Result<()> {
let (action, rest) = rest
.split_first()
.context("usage: passbox plugin <name> <action> [--key value]")?;
let params = parse_params(rest)?;
if store.host().is_some() {
let pairs: Vec<(String, String)> = params.into_iter().collect();
let output = broker::plugin_run_remote(store, name, action, &pairs, &agent())?;
print!("{output}");
if !output.ends_with('\n') {
println!();
}
return Ok(());
}
#[cfg(feature = "host")]
{
plugin_run_local(store, name, action, params)
}
#[cfg(not(feature = "host"))]
{
let _ = (name, action, params);
bail!("no host set, put a Mac's tailnet address in ~/.passbox/host")
}
}
#[cfg(feature = "host")]
fn plugin_run_local(
store: &Store,
name: &str,
action: &str,
params: std::collections::HashMap<String, String>,
) -> Result<()> {
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(¶ms)?;
if !manifest.runs_in_caller_dir() {
command.current_dir(store.plugins_dir().join(name));
}
if !headless() && store.has_se_wrap() {
broker::plugin_approve(store, name, action, &agent())?;
} else {
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));
}
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);
without_credentials(&mut 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 !brokered(store) {
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!("pass the token on stdout back as PASSBOX_TOKEN, it stops working when it lapses");
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 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);
}
}