#![expect(
clippy::print_stdout,
clippy::print_stderr,
reason = "a CLI talks on stdout and stderr, and the broker logs to stderr for launchd"
)]
mod broker;
mod cmd;
mod credentials;
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 skill;
#[cfg(feature = "host")]
mod ssh;
mod store;
#[cfg(feature = "host")]
mod sync;
#[cfg(feature = "host")]
mod tailnet;
mod tree;
#[cfg(feature = "host")]
mod yubikey;
pub(crate) use cmd::exec::run_child;
#[cfg(feature = "host")]
pub(crate) use cmd::plugins::first_party;
pub(crate) use credentials::{held_token, without_credentials};
use age::x25519;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use cmd::exec::exec;
use cmd::git::{git, written};
#[cfg(feature = "host")]
use cmd::imports::{Manager, import};
use cmd::plugins::plugin_cmd;
#[cfg(feature = "host")]
use cmd::secrets::audit;
use cmd::secrets::{Existing, Input, Notes, add, copy, get, grant, ls, restore, rm};
use cmd::setup::init;
#[cfg(feature = "host")]
use cmd::setup::{enable_sync, machine_add, run_sync, yubikey_add};
use credentials::{held_passphrase, take_credentials, take_stdin_token};
#[cfg(feature = "host")]
use import::Mode;
use secrecy::SecretString;
use std::alloc::{GlobalAlloc, Layout, System};
use store::Store;
use zeroize::Zeroize;
struct WipeOnFree<A>(A);
unsafe impl<A: GlobalAlloc> GlobalAlloc for WipeOnFree<A> {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
unsafe { self.0.alloc(layout) }
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
let block = unsafe { std::slice::from_raw_parts_mut(ptr, layout.size()) };
block.zeroize();
unsafe { self.0.dealloc(ptr, layout) };
}
}
#[global_allocator]
static ALLOCATOR: WipeOnFree<System> = WipeOnFree(System);
#[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(AddArgs),
Get {
name: String,
#[arg(long)]
field: Option<String>,
},
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)]
r#for: Option<u64>,
#[arg(long)]
token_stdin: bool,
},
Ls,
Rm { name: String },
Restore {
name: String,
#[arg(long)]
index: Option<usize>,
},
#[cfg(feature = "host")]
MachineAdd,
#[cfg(feature = "host")]
YubikeyAdd {
recipient: Option<String>,
},
Exec {
#[arg(long = "env", value_name = "VAR=SECRET")]
envs: Vec<String>,
#[arg(long)]
stdin: Option<String>,
#[arg(long = "file", value_name = "VAR=SECRET")]
files: Vec<String>,
#[arg(long)]
token_stdin: bool,
#[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")]
Import {
manager: Manager,
from: Option<String>,
#[arg(long)]
prefix: Option<String>,
#[arg(long)]
dry_run: bool,
},
Plugin {
#[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
args: Vec<String>,
},
Mcp,
Skill {
#[command(subcommand)]
action: SkillAction,
},
Git {
#[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
args: Vec<String>,
},
#[cfg(feature = "host")]
#[command(hide = true)]
Broker,
}
#[derive(clap::Args)]
struct AddArgs {
name: String,
#[arg(short, long)]
multiline: bool,
#[arg(long = "field", value_name = "NAME")]
fields: Vec<String>,
#[arg(long, conflicts_with_all = ["multiline", "fields"])]
blob: bool,
#[arg(long, value_name = "PATH", conflicts_with_all = ["multiline", "fields"])]
file: Option<std::path::PathBuf>,
#[arg(long)]
replace: bool,
}
impl AddArgs {
fn input(&self) -> Input<'_> {
match (self.file.as_deref(), self.blob) {
(Some(path), _) => Input::File(path),
(None, true) => Input::Blob,
(None, false) => Input::Fields {
names: &self.fields,
notes: if self.multiline {
Notes::Ask
} else {
Notes::Skip
},
},
}
}
fn existing(&self) -> Existing {
if self.replace {
Existing::Replace
} else {
Existing::Refuse
}
}
}
#[derive(Subcommand)]
enum SkillAction {
Install {
#[arg(long)]
dir: Option<std::path::PathBuf>,
},
Check {
#[arg(long)]
dir: Option<std::path::PathBuf>,
},
}
#[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().is_ok_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();
if let Command::Skill { action } = cli.command {
return match action {
SkillAction::Install { dir } => skill::install(dir),
SkillAction::Check { dir } => skill::check(dir),
};
}
let store = Store::open()?;
store.remove_name_index()?;
match cli.command {
Command::Init => init(&store),
Command::Add(args) => add(&store, &args.name, args.input(), args.existing())
.inspect(|()| written(&store, "add a secret")),
Command::Get { name, field } => get(&store, &name, field.as_deref()),
Command::Copy { name, field, clear } => copy(&store, &name, field.as_deref(), clear),
Command::Grant {
names,
r#for,
token_stdin,
} => {
if token_stdin {
take_stdin_token()?;
}
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.as_deref())
.inspect(|()| written(&store, "wrap the store key to a YubiKey")),
Command::Exec {
envs,
stdin,
files,
token_stdin,
command,
} => {
if token_stdin {
take_stdin_token()?;
}
exec(&store, &envs, &files, stdin.as_deref(), &command)
}
#[cfg(feature = "host")]
Command::Audit { tail } => audit(&store, tail),
#[cfg(feature = "host")]
Command::Sync { remote, enable } => {
if enable {
enable_sync(&store)?;
}
run_sync(&store, remote.as_deref())
}
#[cfg(feature = "host")]
Command::Import {
manager,
from,
prefix,
dry_run,
} => {
let mode = if dry_run { Mode::DryRun } else { Mode::Write };
import(&store, manager, from.as_deref(), prefix.as_deref(), mode).inspect(|()| {
if mode == Mode::Write {
written(&store, "import from another password manager");
}
})
}
Command::Plugin { args } => plugin_cmd(&store, &args),
Command::Mcp => mcp::serve(&store),
Command::Git { args } => git(&store, &args),
Command::Skill { .. } => unreachable!("handled before the store opens"),
#[cfg(feature = "host")]
Command::Broker => broker::serve(store),
}
}
fn agent() -> String {
std::env::var("PASSBOX_AGENT").unwrap_or_default()
}
fn read_secret(store: &Store, name: &str) -> Result<String> {
read_secret_as(store, name, &agent())
}
pub(crate) fn agent_read_secret(store: &Store, name: &str, agent: &str) -> Result<String> {
read_secret_as(store, name, agent)
}
fn brokered(store: &Store) -> bool {
store.host().is_some() || (!headless() && store.has_se_wrap())
}
fn read_secret_as(store: &Store, name: &str, agent: &str) -> 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 caller = broker::own_ancestry();
broker::append_audit(store, agent, name, "released", &caller, "")?;
Ok(secret.value.clone())
}
fn headless() -> bool {
held_passphrase().is_some()
}
fn unlock(store: &Store, reason: &str) -> Result<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: ")?))
}
fn ask(prompt: &str) -> Result<String> {
match held_passphrase() {
Some(pass) => Ok(pass.to_string()),
None => Ok(rpassword::prompt_password(prompt)?),
}
}
pub(crate) 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(test)]
mod tests {
use super::WipeOnFree;
use std::alloc::{GlobalAlloc, Layout, System};
struct Spy(std::sync::atomic::AtomicBool);
unsafe impl GlobalAlloc for Spy {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
let zero = unsafe { std::slice::from_raw_parts(ptr, layout.size()) }
.iter()
.all(|&b| b == 0);
self.0.store(zero, std::sync::atomic::Ordering::SeqCst);
unsafe { System.dealloc(ptr, layout) }
}
}
#[test]
fn freed_memory_is_wiped() {
let wiper = WipeOnFree(Spy(std::sync::atomic::AtomicBool::new(false)));
for size in [1, 31, 4099, 1 << 20] {
let layout = Layout::from_size_align(size, 1).unwrap();
let p = unsafe { wiper.alloc(layout) };
unsafe { std::slice::from_raw_parts_mut(p, size) }.fill(0xAB);
wiper.0.0.store(false, std::sync::atomic::Ordering::SeqCst);
unsafe { wiper.dealloc(p, layout) };
assert!(
wiper.0.0.load(std::sync::atomic::Ordering::SeqCst),
"a freed block of {size} bytes was not wiped"
);
}
}
}