use std::io::{self, Read, IsTerminal};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use std::{env, fs, process};
use clap::{Parser, Subcommand};
use rpassword::prompt_password;
use zeroize::Zeroizing;
use secrets_vault::{
is_v1, is_v2, is_valid_key, parse_env_lines, random_bytes, random_salt, v2_create, v2_salt,
MasterSecret, Vault, VaultError, VaultReader,
};
mod backend;
mod gsm;
mod inbox;
mod keychain;
#[cfg(windows)]
mod keychain_win;
mod lease;
mod registry;
mod session;
mod window;
#[cfg(windows)]
mod session_win;
#[cfg(windows)]
mod winacl;
use backend::Backend;
type ServedBatch = Vec<(String, Zeroizing<Vec<u8>>)>;
#[derive(Parser)]
#[command(name = "secrets", version)]
#[command(about = "Encrypted secret manager — AES-256-GCM + PBKDF2")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Set {
key: String,
value: Option<String>,
#[arg(long, conflicts_with = "value")]
stdin: bool,
#[arg(long, short)]
project: Option<String>,
#[arg(long)]
gsm: bool,
#[arg(long)]
remote: bool,
#[arg(long)]
inbox: bool,
},
Gen {
key: String,
#[arg(long, default_value_t = 32)]
bytes: usize,
#[arg(long)]
force: bool,
#[arg(long, short)]
project: Option<String>,
#[arg(long)]
gsm: bool,
#[arg(long)]
remote: bool,
#[arg(long)]
inbox: bool,
},
Get {
key: String,
#[arg(long, short)]
project: Option<String>,
},
Delete { key: String },
List {
#[arg(long)]
names_only: bool,
#[arg(long)]
json: bool,
},
Has {
key: String,
#[arg(long, short)]
project: Option<String>,
#[arg(long, requires = "project")]
scoped_only: bool,
},
Env {
#[arg(long)]
json: bool,
},
Import,
Export,
Unlock {
#[arg(long)]
strict: bool,
#[arg(long)]
no_presence: bool,
},
Lock,
Session {
#[arg(default_value = "60")]
minutes: u64,
},
#[command(hide = true, name = "__session-serve")]
SessionServe {
minutes: u64,
},
Exec {
project: String,
#[arg(long)]
reason: Option<String>,
#[arg(last = true)]
command: Vec<String>,
},
Authorize {
agent: String,
project: String,
#[arg(long)]
session_minutes: Option<u64>,
},
Revoke { agent: String, project: String },
ListProjects {
#[arg(long)]
json: bool,
},
Inbox {
#[command(subcommand)]
sub: InboxCmd,
},
Lease {
#[command(subcommand)]
sub: LeaseCmd,
},
Migrate,
Rekey,
}
#[derive(Subcommand)]
enum InboxCmd {
Init,
List,
Merge,
Drop { name: String },
}
#[derive(Subcommand)]
enum LeaseCmd {
Create {
project: String,
#[arg(long)]
ttl: Option<String>,
#[arg(long)]
agent: Option<String>,
#[arg(long, conflicts_with = "agent")]
any_agent: bool,
},
#[command(visible_alias = "read")]
Get { project: String, key: String },
Status {
#[arg(long)]
json: bool,
},
Revoke { project: String },
}
fn vault_path() -> std::path::PathBuf {
if let Ok(dir) = env::var("SECRETS_DIR") {
return std::path::PathBuf::from(dir).join("vault.qvlt");
}
dirs::home_dir()
.expect("HOME not set")
.join(".config")
.join("secrets")
.join("vault.qvlt")
}
fn secrets_dir() -> PathBuf {
if let Ok(dir) = env::var("SECRETS_DIR") {
return PathBuf::from(dir);
}
dirs::home_dir()
.expect("HOME not set")
.join(".config")
.join("secrets")
}
fn load_manifest(project: &str) -> Result<(Vec<String>, Backend, Option<window::Window>), String> {
let candidates = [PathBuf::from(".secrets.toml"), secrets_dir().join("projects.toml")];
let path = candidates.iter().find(|p| p.exists()).ok_or_else(|| {
"no .secrets.toml in this directory (or projects.toml in your secrets dir)".to_string()
})?;
let content =
fs::read_to_string(path).map_err(|e| format!("reading {}: {e}", path.display()))?;
let table: toml::Table = content
.parse()
.map_err(|e| format!("invalid TOML in {}: {e}", path.display()))?;
let arr = table
.get("projects")
.and_then(|p| p.get(project))
.and_then(|s| s.get("secrets"))
.or_else(|| table.get("backend").and_then(|b| b.get("secrets"))) .or_else(|| table.get("gsm").and_then(|g| g.get("secrets"))) .or_else(|| table.get("secrets")) .ok_or_else(|| {
format!("no secret list for '{project}' (expected [backend].secrets, [gsm].secrets, [projects.{project}].secrets, or a top-level secrets = [...])")
})?;
let names: Vec<String> = arr
.as_array()
.ok_or_else(|| "`secrets` must be an array of names".to_string())?
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect();
if names.is_empty() {
return Err(format!("project '{project}' lists no secrets"));
}
let win = table
.get("projects")
.and_then(|p| p.get(project))
.and_then(|s| s.get("window"))
.map(parse_window)
.transpose()?;
Ok((names, select_backend(&table)?, win))
}
fn parse_window(v: &toml::Value) -> Result<window::Window, String> {
let t = v.as_table().ok_or("window must be a table")?;
let field = |k: &str| t.get(k).and_then(|x| x.as_str()).map(str::to_string);
let grace = |k: &str| -> Result<u64, String> {
match t.get(k) {
None => Ok(30 * 60),
Some(x) => {
let raw = x
.as_str()
.ok_or_else(|| format!("window {k} must be a duration string"))?;
let digits = raw.trim().trim_end_matches(|c: char| c.is_ascii_alphabetic());
if digits.parse::<u64>() == Ok(0) {
return Ok(0);
}
lease::parse_ttl(raw)
}
}
};
let w = window::Window {
hours: field("hours").ok_or("window needs hours = \"HH:MM-HH:MM\"")?,
tz: field("tz").ok_or("window needs tz = an IANA zone name")?,
days: field("days"),
grace_before_secs: grace("grace_before")?,
grace_after_secs: grace("grace_after")?,
until: field("until"),
};
w.validate()?;
Ok(w)
}
fn select_backend(table: &toml::Table) -> Result<Backend, String> {
if let Some(bt) = table.get("backend").and_then(|b| b.as_table()) {
backend::from_table(bt)
} else if let Some(gcp) = table.get("gsm").and_then(|g| g.get("project")).and_then(|p| p.as_str()) {
Ok(Backend::Gsm(gsm::GsmConfig {
project: gcp.to_string(),
account: table
.get("gsm")
.and_then(|g| g.get("account"))
.and_then(|a| a.as_str())
.map(String::from)
.or_else(|| env::var("SECRETS_GSM_ACCOUNT").ok()),
impersonate: table
.get("gsm")
.and_then(|g| g.get("impersonate"))
.and_then(|a| a.as_str())
.map(String::from)
.or_else(|| env::var("SECRETS_GSM_IMPERSONATE").ok()),
}))
} else {
Ok(Backend::Vault)
}
}
fn manifest_backend() -> Result<Backend, String> {
let candidates = [PathBuf::from(".secrets.toml"), secrets_dir().join("projects.toml")];
let path = candidates.iter().find(|p| p.exists()).ok_or_else(|| {
"no .secrets.toml in this directory (or projects.toml in your secrets dir)".to_string()
})?;
let content =
fs::read_to_string(path).map_err(|e| format!("reading {}: {e}", path.display()))?;
let table: toml::Table = content
.parse()
.map_err(|e| format!("invalid TOML in {}: {e}", path.display()))?;
let be = select_backend(&table)?;
if !be.is_external() {
return Err("no external [backend] in .secrets.toml — nothing to write with --remote".into());
}
Ok(be)
}
fn strict_marker_path() -> PathBuf {
secrets_dir().join("strict")
}
fn is_strict_mode() -> bool {
strict_marker_path().exists()
}
fn get_passphrase_prompted(prompt: &str) -> Zeroizing<String> {
if let Ok(pass) = env::var("SECRETS_PASSPHRASE") {
return Zeroizing::new(pass);
}
if env::var_os("SECRETS_NO_KEYCHAIN").is_none() {
match keychain::read(prompt, is_strict_mode()) {
Ok(Some(p)) => return Zeroizing::new(p),
Ok(None) => {} Err(e) => eprintln!("(keychain unavailable: {e})"),
}
}
if io::stdin().is_terminal() {
return prompt_only_passphrase();
}
eprintln!("Vault is locked and there's no terminal to prompt on.");
eprintln!("Run `secrets unlock` once (stores the master key behind Touch ID); after");
eprintln!("that the Touch ID sheet surfaces even from a headless agent invocation.");
process::exit(1);
}
fn exec_prompt(project: &str, keys: &[String], agent: Option<&str>, reason: Option<&str>) -> String {
let key_list = if keys.is_empty() {
"secrets".to_string()
} else if keys.len() <= 4 {
keys.join(", ")
} else {
format!("{} +{} more", keys[..4].join(", "), keys.len() - 4)
};
let mut s = format!("Unlock {key_list} for project “{project}”");
if let Some(a) = agent {
s.push_str(&format!(" — requested by {a}"));
}
if let Some(r) = reason.filter(|r| !r.is_empty()) {
s.push_str(&format!("\nReason: {r}"));
}
s
}
fn prompt_only_passphrase() -> Zeroizing<String> {
if let Ok(pass) = env::var("SECRETS_PASSPHRASE") {
return Zeroizing::new(pass);
}
if !vault_path().exists() {
return prompt_new_vault_passphrase();
}
Zeroizing::new(read_masked("Vault passphrase: "))
}
fn prompt_new_vault_passphrase() -> Zeroizing<String> {
if !io::stdin().is_terminal() {
return Zeroizing::new(prompt_password("Vault passphrase: ").unwrap_or_else(|e| {
eprintln!("Error reading passphrase: {e}");
process::exit(1);
}));
}
eprintln!("No vault yet — this passphrase will create it and become the master key.");
eprintln!("It cannot be recovered or reset if lost, so it must be entered twice.");
loop {
let first = Zeroizing::new(read_masked("New vault passphrase: "));
if first.is_empty() {
eprintln!("Passphrase can't be empty.");
continue;
}
let confirm = Zeroizing::new(read_masked("Confirm passphrase: "));
if *first == *confirm {
return first;
}
eprintln!("Passphrases don't match — try again.");
}
}
#[cfg(target_os = "macos")]
fn read_masked(prompt: &str) -> String {
use std::io::Write;
use std::os::unix::io::AsRawFd;
if !io::stdin().is_terminal() {
return prompt_password(prompt).unwrap_or_default();
}
let fd = io::stdin().as_raw_fd();
let mut orig: libc::termios = unsafe { std::mem::zeroed() };
if unsafe { libc::tcgetattr(fd, &mut orig) } != 0 {
return prompt_password(prompt).unwrap_or_default();
}
let mut raw = orig; raw.c_lflag &= !(libc::ICANON | libc::ECHO);
raw.c_cc[libc::VMIN] = 1;
raw.c_cc[libc::VTIME] = 0;
if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw) } != 0 {
return prompt_password(prompt).unwrap_or_default();
}
struct TermGuard {
fd: libc::c_int,
orig: libc::termios,
}
impl Drop for TermGuard {
fn drop(&mut self) {
unsafe { libc::tcsetattr(self.fd, libc::TCSANOW, &self.orig) };
}
}
let _guard = TermGuard { fd, orig };
let mut err = io::stderr();
let _ = write!(err, "{prompt}");
let _ = err.flush();
let mut buf: Zeroizing<Vec<u8>> = Zeroizing::new(Vec::new());
let mut byte = [0u8; 1];
loop {
let n = unsafe { libc::read(fd, byte.as_mut_ptr() as *mut libc::c_void, 1) };
if n <= 0 {
break; }
match byte[0] {
b'\n' | b'\r' => {
let _ = write!(err, "\r\n");
let _ = err.flush();
break;
}
3 => {
unsafe { libc::tcsetattr(fd, libc::TCSANOW, &orig) };
let _ = write!(err, "\r\n");
let _ = err.flush();
process::exit(130);
}
4 => break, 0x7f | 0x08 => {
if !buf.is_empty() {
while let Some(&b) = buf.last() {
buf.pop();
if (b & 0xC0) != 0x80 {
break; }
}
let _ = write!(err, "\x08 \x08");
let _ = err.flush();
}
}
b => {
buf.push(b);
if (b & 0xC0) != 0x80 {
let _ = write!(err, "•");
let _ = err.flush();
}
}
}
}
String::from_utf8_lossy(&buf).into_owned()
}
#[cfg(not(target_os = "macos"))]
fn read_masked(prompt: &str) -> String {
prompt_password(prompt).unwrap_or_default()
}
fn save_vault(vault: &Vault, passphrase: &str) {
let path = vault_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).ok();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(parent, fs::Permissions::from_mode(0o700)).ok();
}
}
let encrypted = vault.encrypt(passphrase).unwrap_or_else(|e| {
eprintln!("Error encrypting: {e}");
process::exit(1);
});
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)
.unwrap_or_else(|e| {
eprintln!("Error writing vault: {e}");
process::exit(1);
});
io::Write::write_all(&mut file, &encrypted).unwrap();
write_index(vault);
return;
}
#[cfg(not(unix))]
{
fs::write(&path, &encrypted).unwrap_or_else(|e| {
eprintln!("Error writing vault: {e}");
process::exit(1);
});
write_index(vault);
}
}
fn migrate_hint() {
eprintln!("note: this vault is in the legacy v1 format (every read decrypts everything).");
eprintln!(" Run `secrets migrate` once to upgrade to per-entry encryption.");
}
fn read_vault_bytes() -> Option<Vec<u8>> {
match fs::read(vault_path()) {
Ok(d) => Some(d),
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
Err(e) => {
eprintln!("Error reading vault: {e}");
process::exit(1);
}
}
}
enum VaultView {
V2 { master: MasterSecret, reader: VaultReader },
V1 { pass: Zeroizing<String>, vault: Vault },
}
fn view_from_passphrase(pass: Zeroizing<String>) -> VaultView {
match read_vault_bytes() {
Some(d) if is_v2(&d) => {
let salt = v2_salt(&d).unwrap_or_else(|e| {
eprintln!("Error: {e}");
process::exit(1);
});
let master = MasterSecret::derive(&pass, &salt);
drop(pass);
let reader = open_reader(d, &master);
VaultView::V2 { master, reader }
}
Some(d) if is_v1(&d) => {
migrate_hint();
let vault = match Vault::decrypt(&d, &pass) {
Ok(v) => v,
Err(VaultError::DecryptionFailed) => {
eprintln!("Error: wrong passphrase");
process::exit(1);
}
Err(e) => {
eprintln!("Error: {e}");
process::exit(1);
}
};
VaultView::V1 { pass, vault }
}
Some(_) => {
eprintln!("Error: vault file is not a recognized QVLT format");
process::exit(1);
}
None => {
let master = MasterSecret::derive(&pass, &random_salt());
drop(pass);
let out = v2_create(&master, &[]).unwrap_or_else(|e| {
eprintln!("Error creating vault: {e}");
process::exit(1);
});
write_v2_vault(&out, &[]);
let reader = open_reader(out, &master);
VaultView::V2 { master, reader }
}
}
}
fn unlock_view(prompt: &str) -> VaultView {
view_from_passphrase(get_passphrase_prompted(prompt))
}
impl VaultView {
fn contains(&self, name: &str) -> bool {
match self {
Self::V2 { reader, .. } => reader.contains(name),
Self::V1 { vault, .. } => vault.get(name).is_some(),
}
}
fn names(&self) -> Vec<String> {
match self {
Self::V2 { reader, .. } => reader.names().map(String::from).collect(),
Self::V1 { vault, .. } => vault.keys().map(String::from).collect(),
}
}
fn get_project(&self, project: &str, name: &str) -> Option<Zeroizing<Vec<u8>>> {
self.get_one(&format!("{project}/{name}"))
.or_else(|| self.get_one(name))
}
fn get_one(&self, name: &str) -> Option<Zeroizing<Vec<u8>>> {
match self {
Self::V2 { master, reader } => match reader.decrypt_one(master, name) {
Ok(v) => Some(v),
Err(VaultError::NotFound) => None,
Err(e) => {
eprintln!("Error: {e}");
process::exit(1);
}
},
Self::V1 { vault, .. } => {
vault.get(name).map(|v| Zeroizing::new(v.as_bytes().to_vec()))
}
}
}
fn write(&mut self, upserts: Vec<(String, Zeroizing<Vec<u8>>)>, deletes: Vec<String>) {
match self {
Self::V2 { master, reader } => {
let out = reader.splice(master, &upserts, &deletes).unwrap_or_else(|e| {
eprintln!("Error encrypting: {e}");
process::exit(1);
});
let mut names: std::collections::BTreeSet<String> =
reader.names().map(String::from).collect();
for d in &deletes {
names.remove(d);
}
for (n, _) in &upserts {
names.insert(n.clone());
}
let names: Vec<String> = names.into_iter().collect();
write_v2_vault(&out, &names);
*reader = open_reader(out, master); }
Self::V1 { pass, vault } => {
for d in &deletes {
vault.delete(d);
}
for (n, v) in upserts {
vault.set(n, String::from_utf8_lossy(&v).into_owned());
}
save_vault(vault, pass);
}
}
}
fn decrypt_all(&self) -> Vault {
match self {
Self::V2 { master, reader } => reader.decrypt_all(master).unwrap_or_else(|e| {
eprintln!("Error: {e}");
process::exit(1);
}),
Self::V1 { vault, .. } => vault.clone(),
}
}
fn registry_load(&self, dir: &Path) -> Result<registry::Registry, String> {
match self {
Self::V2 { master, .. } => registry::Registry::load_raw(dir, &master.registry_key()),
Self::V1 { pass, .. } => registry::Registry::load_v1(dir, pass),
}
}
fn registry_save(&self, reg: ®istry::Registry, dir: &Path) -> Result<(), String> {
match self {
Self::V2 { master, .. } => reg.save_raw(dir, &master.registry_key()),
Self::V1 { pass, .. } => reg.save_v1(dir, pass),
}
}
}
fn open_reader(data: Vec<u8>, master: &MasterSecret) -> VaultReader {
match VaultReader::open(data, master) {
Ok(r) => r,
Err(VaultError::DecryptionFailed) => {
eprintln!("Error: wrong passphrase (or tampered vault)");
process::exit(1);
}
Err(e) => {
eprintln!("Error: {e}");
process::exit(1);
}
}
}
fn write_v2_vault(bytes: &[u8], names: &[String]) {
let path = vault_path();
let parent = path.parent().expect("vault path has a parent").to_path_buf();
fs::create_dir_all(&parent).ok();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&parent, fs::Permissions::from_mode(0o700)).ok();
}
let tmp = parent.join(".vault.qvlt.tmp");
#[cfg(unix)]
let mut file = {
use std::os::unix::fs::OpenOptionsExt;
fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp)
.unwrap_or_else(|e| {
eprintln!("Error writing vault: {e}");
process::exit(1);
})
};
#[cfg(not(unix))]
let mut file = fs::File::create(&tmp).unwrap_or_else(|e| {
eprintln!("Error writing vault: {e}");
process::exit(1);
});
io::Write::write_all(&mut file, bytes).unwrap_or_else(|e| {
eprintln!("Error writing vault: {e}");
process::exit(1);
});
file.sync_all().ok();
drop(file);
fs::rename(&tmp, &path).unwrap_or_else(|e| {
eprintln!("Error replacing vault: {e}");
process::exit(1);
});
if let Ok(dir) = fs::File::open(&parent) {
dir.sync_all().ok(); }
write_index_names(names);
}
fn index_path() -> PathBuf {
secrets_dir().join("index.json")
}
fn write_index(vault: &Vault) {
let names: Vec<String> = vault.keys().map(String::from).collect();
write_index_names(&names);
}
fn write_index_names(names: &[String]) {
let body = names.join("\n");
let path = index_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).ok();
}
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
if let Ok(mut f) = fs::OpenOptions::new()
.write(true).create(true).truncate(true).mode(0o600).open(&path)
{
io::Write::write_all(&mut f, body.as_bytes()).ok();
}
}
#[cfg(not(unix))]
{
fs::write(&path, body.as_bytes()).ok();
}
}
fn read_index() -> Vec<String> {
fs::read_to_string(index_path())
.map(|s| s.lines().filter(|l| !l.is_empty()).map(String::from).collect())
.unwrap_or_default()
}
fn scoped_key(project: &Option<String>, key: &str) -> String {
if !is_valid_key(key) {
eprintln!("Invalid key: '{key}' (use A-Z, 0-9, _, -)");
process::exit(1);
}
match project {
Some(p) => {
if !secrets_vault::is_valid_project(p) {
eprintln!("Invalid project: '{p}' (use A-Z, 0-9, _, -, .)");
process::exit(1);
}
format!("{p}/{key}")
}
None => key.to_string(),
}
}
fn gsm_config(project: String) -> gsm::GsmConfig {
gsm::GsmConfig {
project,
account: env::var("SECRETS_GSM_ACCOUNT").ok(),
impersonate: env::var("SECRETS_GSM_IMPERSONATE").ok(),
}
}
fn approval_dir() -> PathBuf {
if let Ok(d) = env::var("SECRETS_APPROVAL_DIR") {
return PathBuf::from(d);
}
dirs::home_dir()
.expect("HOME not set")
.join(".secrets")
.join("pending_approvals")
}
fn ipc_approval(
agent: &str,
project: &str,
command: &str,
keys: &[String],
reason: Option<&str>,
reg_dir: &Path,
view: &VaultView,
) -> bool {
let dir = approval_dir();
if fs::create_dir_all(&dir).is_err() {
eprintln!("Could not create approval dir {}", dir.display());
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
}
let id = to_hex(&random_bytes(16));
let req_path = dir.join(format!("{id}.json"));
let resp_path = dir.join(format!("{id}_response.json"));
let mut req = serde_json::json!({
"id": id, "agent": agent, "project": project, "command": command, "keys": keys,
});
if let Some(r) = reason.filter(|r| !r.is_empty()) {
req["reason"] = serde_json::Value::String(r.to_string());
}
if fs::write(&req_path, serde_json::to_vec_pretty(&req).unwrap_or_default()).is_err() {
eprintln!("Could not write approval request.");
return false;
}
eprintln!("Waiting for approval in aiconductor… ({agent} → {project})");
let timeout = env::var("SECRETS_APPROVAL_TIMEOUT_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(30);
let start = Instant::now();
let granted = loop {
if resp_path.exists() {
let reg = view.registry_load(reg_dir).unwrap_or_default();
break reg.grant_for(agent, project, registry::now()).is_some();
}
if start.elapsed().as_secs() >= timeout {
eprintln!("Approval timed out (is aiconductor running?).");
break false;
}
std::thread::sleep(Duration::from_millis(100));
};
let _ = fs::remove_file(&req_path);
let _ = fs::remove_file(&resp_path);
granted
}
fn to_hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(s, "{b:02x}");
}
s
}
fn eval_warning() {
let (red, bold, off) = if io::stderr().is_terminal() {
("\x1b[1;31m", "\x1b[1m", "\x1b[0m")
} else {
("", "", "")
};
eprintln!("{red}⚠️ WARNING:{off} `secrets env` dumps your ENTIRE vault into the shell");
eprintln!(" environment — every secret is then exposed to ALL child processes");
eprintln!(" (including untrusted npm/pip postinstall scripts).");
eprintln!(" Use {bold}secrets exec <project> -- <cmd>{off} for scoped, child-only injection.");
}
#[cfg(windows)]
fn harden_secrets_dirs() {
let dir = secrets_dir();
if fs::create_dir_all(&dir).is_ok() {
winacl::restrict_to_owner_warn(&dir);
}
let approvals = approval_dir();
if let Some(parent) = approvals.parent() {
if fs::create_dir_all(parent).is_ok() {
winacl::restrict_to_owner_warn(parent);
}
}
}
fn broker_outcome(log: &Path, offset: u64) -> Option<Result<(), String>> {
let data = fs::read(log).ok()?;
if (data.len() as u64) <= offset {
return None;
}
let tail = String::from_utf8_lossy(&data[offset as usize..]);
for line in tail.lines() {
if let Some(i) = line.find(" FAILED ") {
return Some(Err(line[i + " FAILED ".len()..].to_string()));
}
if line.contains(" START lifetime=") {
return Some(Ok(()));
}
}
None
}
fn main() {
let cli = Cli::parse();
#[cfg(windows)]
harden_secrets_dirs();
match cli.command {
Commands::Set { key, value, stdin, project, gsm, remote, inbox } => {
if !is_valid_key(&key) {
eprintln!("Invalid key: '{key}' (use A-Z, 0-9, _, -)");
process::exit(1);
}
let value = match value {
Some(v) => v,
None => {
if stdin || !io::stdin().is_terminal() {
let mut buf = String::new();
io::stdin().read_to_string(&mut buf).expect("Failed to read stdin");
let trimmed = buf.strip_suffix('\n').unwrap_or(&buf);
let trimmed = trimmed.strip_suffix('\r').unwrap_or(trimmed);
trimmed.to_string()
} else {
read_masked(&format!("Enter value for {key}: "))
}
}
};
if value.is_empty() {
eprintln!("Error: empty value");
process::exit(1);
}
if inbox {
if gsm || remote {
eprintln!("--inbox seals into the local vault inbox; not valid with --gsm/--remote.");
process::exit(1);
}
let storage = scoped_key(&project, &key);
match inbox::append(&secrets_dir(), &storage, &value) {
Ok(count) => eprintln!(
"Sealed {storage} into the inbox ({count} pending) — no Touch ID. \
Merge later: secrets inbox merge"
),
Err(e) => {
eprintln!("Inbox error: {e}");
process::exit(1);
}
}
} else if remote {
let be = manifest_backend().unwrap_or_else(|e| {
eprintln!("Error: {e}");
process::exit(1);
});
match be.add(&key, &value) {
Ok(()) => eprintln!("Stored {key} → {}.", be.label()),
Err(e) => {
eprintln!("{} error: {e}", be.label());
process::exit(1);
}
}
} else if gsm {
let proj = project.unwrap_or_else(|| {
eprintln!("--gsm requires --project <gcp-project>");
process::exit(1);
});
let cfg = gsm_config(proj);
match gsm::add(&cfg, &key, &value) {
Ok(()) => eprintln!("Stored {key} → GSM project '{}'.", cfg.project),
Err(e) => {
eprintln!("GSM error: {e}");
process::exit(1);
}
}
} else {
let storage = scoped_key(&project, &key);
let mut view = unlock_view("Unlock your secrets vault");
view.write(
vec![(storage.clone(), Zeroizing::new(value.into_bytes()))],
vec![],
);
eprintln!("Stored: {storage}");
}
}
Commands::Gen { key, bytes, force, project, gsm, remote, inbox } => {
if !is_valid_key(&key) {
eprintln!("Invalid key: '{key}' (use A-Z, 0-9, _, -)");
process::exit(1);
}
if bytes == 0 || bytes > 1024 {
eprintln!("Error: --bytes must be between 1 and 1024");
process::exit(1);
}
let value = to_hex(&random_bytes(bytes));
if inbox {
if gsm || remote {
eprintln!("--inbox seals into the local vault inbox; not valid with --gsm/--remote.");
process::exit(1);
}
let _ = force;
let storage = scoped_key(&project, &key);
match inbox::append(&secrets_dir(), &storage, &value) {
Ok(count) => eprintln!(
"Generated {storage} ({bytes} bytes) → sealed into the inbox ({count} pending), \
value never printed. Merge later: secrets inbox merge"
),
Err(e) => {
eprintln!("Inbox error: {e}");
process::exit(1);
}
}
} else if remote {
let be = manifest_backend().unwrap_or_else(|e| {
eprintln!("Error: {e}");
process::exit(1);
});
match be.add(&key, &value) {
Ok(()) => eprintln!(
"Generated {key} ({bytes} bytes) → {} — value never printed.",
be.label()
),
Err(e) => {
eprintln!("{} error: {e}", be.label());
process::exit(1);
}
}
} else if gsm {
let proj = project.unwrap_or_else(|| {
eprintln!("--gsm requires --project <gcp-project>");
process::exit(1);
});
let cfg = gsm_config(proj);
match gsm::add(&cfg, &key, &value) {
Ok(()) => eprintln!(
"Generated {key} ({bytes} bytes) → GSM project '{}' — value never printed.",
cfg.project
),
Err(e) => {
eprintln!("GSM error: {e}");
process::exit(1);
}
}
} else {
let storage = scoped_key(&project, &key);
let mut view = unlock_view("Unlock your secrets vault");
if view.contains(&storage) && !force {
eprintln!("'{storage}' already exists — use --force to regenerate (overwrites).");
process::exit(1);
}
view.write(
vec![(storage.clone(), Zeroizing::new(value.into_bytes()))],
vec![],
);
eprintln!("Generated {storage} ({bytes} random bytes, hex) — value stored, never printed.");
}
}
Commands::Get { key, project } => {
let storage = scoped_key(&project, &key);
if let Some(p) = &project {
if let Some(value) = session::request_value(&secrets_dir(), p, &key) {
io::Write::write_all(&mut io::stdout(), &value).ok();
return;
}
if let Ok(ks) = lease::keystore_from_env() {
if let lease::ReadOutcome::Served(value) = lease::read(
&secrets_dir(),
ks,
p,
&key,
registry::resolve_agent().as_deref(),
lease::now(),
) {
io::Write::write_all(&mut io::stdout(), &value).ok();
return;
}
}
}
let view = unlock_view(&format!("Read {storage} from your secrets vault"));
let found = match &project {
Some(p) => view.get_project(p, &key),
None => view.get_one(&storage),
};
match found {
Some(value) => {
io::Write::write_all(&mut io::stdout(), &value).ok();
}
None => {
eprintln!("Not found: {key}");
process::exit(1);
}
}
}
Commands::Delete { key } => {
let mut view = unlock_view(&format!("Delete {key} from your secrets vault"));
if !view.contains(&key) {
eprintln!("Not found: {key}");
process::exit(1);
}
view.write(vec![], vec![key.clone()]);
eprintln!("Deleted: {key}");
}
Commands::List { names_only, json } => {
let names = if names_only {
read_index()
} else {
let view = unlock_view("List your secrets vault");
let names = view.names();
write_index_names(&names); names
};
if json {
let keys: Vec<serde_json::Value> = names
.iter()
.map(|k| match k.split_once('/') {
Some((p, name)) => serde_json::json!({ "project": p, "name": name }),
None => serde_json::json!({ "project": null, "name": k }),
})
.collect();
println!("{}", serde_json::json!({ "keys": keys }));
} else {
for key in &names {
println!("{key}");
}
}
}
Commands::Has { key, project, scoped_only } => {
let storage = scoped_key(&project, &key);
let index = read_index();
let scoped_hit = project.is_some() && index.iter().any(|k| k == &storage);
let bare_hit = index.iter().any(|k| k == &key);
let exists = if scoped_only {
scoped_hit
} else if project.is_some() {
scoped_hit || bare_hit
} else {
bare_hit
};
if project.is_some() && !scoped_only && exists {
if scoped_hit {
eprintln!("(scoped entry {storage})");
} else {
eprintln!("(bare entry {key} — no scoped copy for this project)");
}
}
println!("{exists}");
process::exit(if exists { 0 } else { 1 });
}
Commands::Env { json } => {
if !json {
eval_warning();
}
let view = unlock_view("Dump your ENTIRE secrets vault (env)");
let vault = view.decrypt_all();
if json {
print!("{}", vault.to_json());
} else {
print!("{}", vault.to_shell_exports());
}
}
Commands::Import => {
let mut input = String::new();
io::stdin()
.read_to_string(&mut input)
.expect("Failed to read stdin");
let pairs = parse_env_lines(&input);
let count = pairs.len();
let upserts: Vec<(String, Zeroizing<Vec<u8>>)> = pairs
.into_iter()
.map(|(k, v)| (k, Zeroizing::new(v.into_bytes())))
.collect();
let mut view = unlock_view("Import secrets into your vault");
view.write(upserts, vec![]);
eprintln!("Imported {count} secrets");
}
Commands::Export => {
let view = unlock_view("Export your ENTIRE secrets vault");
let vault = view.decrypt_all();
for (key, value) in vault.iter() {
if value.contains('\n') {
eprintln!(
"warning: '{key}' is multi-line; the KEY=VALUE export format cannot \
round-trip it via `import`. Use `secrets get {key}` to retrieve it intact."
);
}
println!("{key}={value}");
}
}
Commands::Unlock { strict, no_presence } => {
#[cfg(not(windows))]
if no_presence {
eprintln!("--no-presence is a Windows-only option (it disables the Windows Hello gate).");
process::exit(2);
}
#[cfg(windows)]
if strict {
eprintln!(
"note: --strict has no Windows equivalent — Windows Hello exposes no \
biometry-only mode. Windows reads are already prompt-every-time."
);
}
let pass = prompt_only_passphrase();
if let Ok(data) = fs::read(vault_path()) {
let ok = if is_v2(&data) {
v2_salt(&data)
.map(|salt| {
let m = MasterSecret::derive(&pass, &salt);
VaultReader::open(data, &m).is_ok()
})
.unwrap_or(false)
} else {
Vault::decrypt(&data, &pass).is_ok()
};
if !ok {
eprintln!("Wrong passphrase — nothing stored.");
process::exit(1);
}
}
#[cfg(windows)]
let stored = if no_presence {
keychain_win::store_no_presence(&pass)
} else {
keychain::store(&pass, strict)
};
#[cfg(not(windows))]
let stored = keychain::store(&pass, strict);
match stored {
Ok(()) => {
let marker = strict_marker_path();
if strict {
if let Some(parent) = marker.parent() {
fs::create_dir_all(parent).ok();
}
let _ = fs::write(&marker, b"1");
} else {
let _ = fs::remove_file(&marker);
}
#[cfg(windows)]
if no_presence {
eprintln!(
"Unlocked WITHOUT a presence gate. The master key is DPAPI-encrypted \
to your Windows account, but any process running as you can open it \
with no prompt. Re-run `secrets unlock` (no flag) to restore the \
Windows Hello gate."
);
} else {
eprintln!(
"Unlocked. Master key sealed with DPAPI (your Windows account) — \
Windows Hello required on every read."
);
}
#[cfg(target_os = "macos")]
if strict {
eprintln!(
"Unlocked (STRICT). Enrolled biometry only, no reuse — a fresh Touch ID tap is required on every access."
);
} else {
eprintln!(
"Unlocked. Master key stored in the biometric Keychain — Touch ID required on read (with the system reuse grace)."
);
}
#[cfg(not(any(windows, target_os = "macos")))]
eprintln!("Unlocked.");
}
Err(e) => {
eprintln!("Storing the master key failed: {e}");
process::exit(1);
}
}
}
Commands::Lock => match keychain::delete() {
Ok(()) => {
let _ = fs::remove_file(strict_marker_path());
session::end(&secrets_dir()); if let Ok(ks) = lease::keystore_from_env() {
let n = lease::revoke_all(&secrets_dir(), ks);
if n > 0 {
eprintln!("Revoked {n} live lease(s).");
}
}
#[cfg(windows)]
eprintln!("Locked. DPAPI master-key entry removed.");
#[cfg(not(windows))]
eprintln!("Locked. Biometric Keychain entry removed.");
}
Err(e) => {
eprintln!("Removing the stored master key failed: {e}");
process::exit(1);
}
},
Commands::Session { minutes } => {
{
let Some(data) = read_vault_bytes() else {
eprintln!("No vault yet — nothing to serve. Store a secret first.");
process::exit(1);
};
if !is_v2(&data) {
migrate_hint();
eprintln!("`secrets session` requires a v2 vault.");
process::exit(1);
}
let pass = get_passphrase_prompted("Start a secrets session (unattended drain)");
let salt = v2_salt(&data).unwrap_or_else(|e| {
eprintln!("Error: {e}");
process::exit(1);
});
let master = MasterSecret::derive(&pass, &salt);
if VaultReader::open(data, &master).is_err() {
eprintln!("Wrong passphrase — session not started.");
process::exit(1);
}
drop(master);
let log_path = secrets_dir().join("session.log");
let log_before = fs::metadata(&log_path).map(|m| m.len()).unwrap_or(0);
let exe = env::current_exe().unwrap_or_else(|_| PathBuf::from("secrets"));
let mut cmd = process::Command::new(exe);
cmd.arg("__session-serve")
.arg(minutes.to_string())
.stdin(process::Stdio::piped())
.stdout(process::Stdio::null())
.stderr(process::Stdio::null());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0000_0008 | 0x0000_0200);
unsafe {
use windows::Win32::Foundation::{
SetHandleInformation, HANDLE_FLAGS, HANDLE_FLAG_INHERIT,
};
use windows::Win32::System::Console::{
GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE,
};
for id in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] {
if let Ok(h) = GetStdHandle(id) {
let _ = SetHandleInformation(h, HANDLE_FLAG_INHERIT.0, HANDLE_FLAGS(0));
}
}
}
}
let mut child = cmd.spawn().unwrap_or_else(|e| {
eprintln!("Failed to start session broker: {e}");
process::exit(1);
});
if let Some(mut sin) = child.stdin.take() {
use std::io::Write as _;
let _ = sin.write_all(pass.as_bytes());
}
let mut failure: Option<String> = None;
let mut live = false;
for _ in 0..100 {
match broker_outcome(&log_path, log_before) {
Some(Ok(())) => {
live = true;
break;
}
Some(Err(why)) => {
failure = Some(why);
break;
}
None => {}
}
if matches!(child.try_wait(), Ok(Some(_))) {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
if live {
if let session::Probe::Foreign(why) = session::probe(&secrets_dir()) {
eprintln!("Refusing to report a session: {why}");
process::exit(1);
}
} else {
eprintln!("Session broker failed to start — no key server is running.");
match failure {
Some(why) => eprintln!("{why}"),
None => eprintln!(
"It exited without recording a reason; see {}",
secrets_dir().join("session.log").display()
),
}
process::exit(1);
}
eprintln!(
"Session started — `secrets exec` runs tap-free for {minutes} min. \
`secrets lock` ends it early."
);
}
}
Commands::SessionServe { minutes } => {
use std::io::Read as _;
let mut pass = String::new();
if std::io::stdin().read_to_string(&mut pass).is_err() {
process::exit(1);
}
let pass = Zeroizing::new(pass);
if pass.is_empty() {
process::exit(1);
}
session::detach();
if let Err(e) = session::serve(&secrets_dir(), minutes, pass) {
session::audit(&secrets_dir(), &format!("FAILED {e}"));
eprintln!("session broker: {e}");
process::exit(1);
}
return;
}
Commands::Exec { project, reason, command } => {
if command.is_empty() {
eprintln!("Usage: secrets exec <project> -- <command> [args...]");
process::exit(2);
}
if !secrets_vault::is_valid_project(&project) {
eprintln!("Invalid project: '{project}'");
process::exit(1);
}
let (names, be, _window) = load_manifest(&project).unwrap_or_else(|e| {
eprintln!("Error: {e}");
process::exit(1);
});
let dir = secrets_dir();
let mut cmd = process::Command::new(&command[0]);
cmd.args(&command[1..]);
cmd.env_remove("SECRETS_PASSPHRASE");
let mut tapfree_served: Option<(ServedBatch, String)> = None;
if matches!(be, Backend::Vault) {
let mut got = Vec::with_capacity(names.len());
for name in &names {
match session::request_value(&dir, &project, name) {
Some(v) => got.push((name.clone(), v)),
None => {
got.clear();
break;
}
}
}
if got.len() == names.len() && !names.is_empty() {
tapfree_served =
Some((got, "session broker (grant-checked, tap-free)".to_string()));
}
if tapfree_served.is_none() {
if let Ok(ks) = lease::keystore_from_env() {
let caller = registry::resolve_agent();
let t = lease::now();
let mut got = Vec::with_capacity(names.len());
for name in &names {
match lease::read(&dir, ks, &project, name, caller.as_deref(), t) {
lease::ReadOutcome::Served(v) => got.push((name.clone(), v)),
_ => {
got.clear();
break;
}
}
}
if got.len() == names.len() && !names.is_empty() {
let left = lease::expires_at(&dir, ks, &project)
.map(|e| lease::human_secs(e.saturating_sub(t)))
.unwrap_or_else(|| "?".to_string());
tapfree_served =
Some((got, format!("project lease (tap-free, {left} left)")));
}
}
}
}
let mut injected = 0usize;
let mut missing: Vec<&str> = Vec::new();
if let Some((served, via)) = &tapfree_served {
eprintln!("secrets exec: {} secret(s) via {via}", served.len());
for (name, value) in served {
cmd.env(name, std::ffi::OsStr::new(&String::from_utf8_lossy(value).into_owned()));
injected += 1;
}
} else {
let agent = registry::resolve_agent();
let prompt = exec_prompt(&project, &names, agent.as_deref(), reason.as_deref());
let view = unlock_view(&prompt);
let reg = view.registry_load(&dir).unwrap_or_else(|e| {
eprintln!("Error: {e}");
process::exit(1);
});
if let Some(agent) = agent {
if reg.grant_for(&agent, &project, registry::now()).is_none()
&& !ipc_approval(
&agent, &project, &command[0], &names, reason.as_deref(), &dir, &view,
)
{
eprintln!("'{agent}' was not granted access to '{project}'.");
eprintln!("Or pre-authorize out-of-band: secrets authorize {agent} {project}");
process::exit(1);
}
eprintln!("secrets exec: agent '{agent}' authorized for '{project}'.");
}
match &be {
Backend::Vault => {
for name in &names {
match view.get_project(&project, name) {
Some(value) => {
cmd.env(
name,
std::ffi::OsStr::new(
&String::from_utf8_lossy(&value).into_owned(),
),
);
injected += 1;
}
None => missing.push(name.as_str()),
}
}
if !missing.is_empty() {
eprintln!("warning: not in vault, skipped: {}", missing.join(", "));
}
}
external => {
eprintln!(
"secrets exec: pulling {} secret(s) from {}",
names.len(),
external.label()
);
for name in &names {
match external.access(name) {
Ok(value) => {
cmd.env(name, value);
injected += 1;
}
Err(e) => {
eprintln!(" ! {name}: {e}");
missing.push(name.as_str());
}
}
}
if !missing.is_empty() {
eprintln!(
"warning: could not fetch from {}, skipped: {}",
external.label(),
missing.join(", ")
);
}
}
}
}
eprintln!(
"secrets exec: injecting {injected} secret(s) into `{}` (project: {project})",
command[0]
);
let mut child = cmd.spawn().unwrap_or_else(|e| {
eprintln!("Failed to spawn `{}`: {e}", command[0]);
process::exit(127);
});
let status = child.wait().unwrap_or_else(|e| {
eprintln!("Failed waiting for child: {e}");
process::exit(1);
});
#[cfg(unix)]
let code = {
use std::os::unix::process::ExitStatusExt;
status
.code()
.or_else(|| status.signal().map(|s| 128 + s))
.unwrap_or(1)
};
#[cfg(not(unix))]
let code = status.code().unwrap_or(1);
process::exit(code);
}
Commands::Authorize { agent, project, session_minutes } => {
let view = unlock_view(&format!("Authorize '{agent}' for project '{project}'")); let dir = secrets_dir();
let mut reg = view.registry_load(&dir).unwrap_or_else(|e| {
eprintln!("Error: {e}");
process::exit(1);
});
let scope = match session_minutes {
Some(m) => registry::Scope::Session { expires: registry::now() + m * 60 },
None => registry::Scope::Always,
};
reg.set_grant(&agent, &project, scope);
view.registry_save(®, &dir).unwrap_or_else(|e| {
eprintln!("Error: {e}");
process::exit(1);
});
match session_minutes {
Some(m) => eprintln!("Authorized '{agent}' → '{project}' for {m} min."),
None => eprintln!("Authorized '{agent}' → '{project}' (permanent)."),
}
}
Commands::Revoke { agent, project } => {
let view = unlock_view(&format!("Revoke '{agent}' from project '{project}'"));
let dir = secrets_dir();
let mut reg = view.registry_load(&dir).unwrap_or_else(|e| {
eprintln!("Error: {e}");
process::exit(1);
});
if reg.revoke(&agent, &project) {
view.registry_save(®, &dir).unwrap_or_else(|e| {
eprintln!("Error: {e}");
process::exit(1);
});
eprintln!("Revoked '{agent}' → '{project}'.");
} else {
eprintln!("No grant for '{agent}' → '{project}'.");
process::exit(1);
}
}
Commands::ListProjects { json } => {
let view = unlock_view("List projects and agent grants");
let dir = secrets_dir();
let reg = view.registry_load(&dir).unwrap_or_else(|e| {
eprintln!("Error: {e}");
process::exit(1);
});
if json {
let now = registry::now();
let projects: Vec<serde_json::Value> = reg
.projects
.iter()
.map(|(name, meta)| {
serde_json::json!({
"name": name,
"gcp_project": meta.gcp_project,
"keys": meta.keys.len(),
})
})
.collect();
let mut grants: Vec<serde_json::Value> = Vec::new();
for (agent, projs) in ®.grants {
for (project, grant) in projs {
let (scope, left) = match grant.scope {
registry::Scope::Always => ("permanent", None),
registry::Scope::Session { expires } if expires > now => {
("session", Some(expires - now))
}
registry::Scope::Session { .. } => ("expired", Some(0)),
};
grants.push(serde_json::json!({
"agent": agent,
"project": project,
"scope": scope,
"expires_in_secs": left,
}));
}
}
println!(
"{}",
serde_json::json!({ "projects": projects, "grants": grants })
);
return;
}
if reg.projects.is_empty() && reg.grants.is_empty() {
println!("(registry empty — no projects or grants yet)");
} else {
let now = registry::now();
if !reg.projects.is_empty() {
println!("Projects:");
for (name, meta) in ®.projects {
println!(" {name} → {} ({} keys)", meta.gcp_project, meta.keys.len());
}
}
println!("Grants:");
for (agent, projs) in ®.grants {
for (project, grant) in projs {
let scope = match grant.scope {
registry::Scope::Always => "permanent".to_string(),
registry::Scope::Session { expires } if expires > now => {
format!("session, {}m left", (expires - now) / 60)
}
registry::Scope::Session { .. } => "expired".to_string(),
};
println!(" {agent} → {project} [{scope}]");
}
}
}
}
Commands::Lease { sub } => {
let dir = secrets_dir();
let ks = lease::keystore_from_env().unwrap_or_else(|e| {
eprintln!("Error: {e}");
process::exit(1);
});
match sub {
LeaseCmd::Create { project, ttl, agent, any_agent } => {
if !secrets_vault::is_valid_project(&project) {
eprintln!("Invalid project: '{project}'");
process::exit(1);
}
let (names, be, win) = load_manifest(&project).unwrap_or_else(|e| {
eprintln!("Error: {e}");
process::exit(1);
});
let now = lease::now();
let explicit_ttl = ttl.as_deref().map(|t| {
lease::parse_ttl(t).unwrap_or_else(|e| {
eprintln!("Error: {e}");
process::exit(1);
})
});
let until_end: Option<u64> = win
.as_ref()
.and_then(|w| w.end_of_authorisation().transpose())
.transpose()
.unwrap_or_else(|e| {
eprintln!("Error: {e}");
process::exit(1);
})
.map(|t| t.max(0) as u64);
if let Some(end) = until_end {
if end <= now {
eprintln!(
"Error: the window for '{project}' ended on its `until` date — nothing left to lease."
);
process::exit(1);
}
}
let ttl_secs = match (explicit_ttl, until_end) {
(Some(t), Some(end)) => t.min(end - now),
(Some(t), None) => t,
(None, Some(end)) => end - now,
(None, None) => lease::parse_ttl(lease::DEFAULT_TTL).expect("default TTL parses"),
};
if win.is_none() && ttl_secs > lease::TTL_WARN_SECS {
eprintln!(
"⚠️ TTL {} exceeds 24h — that is a long tap-free window; consider shorter.",
lease::human_secs(ttl_secs)
);
}
if !matches!(be, Backend::Vault) {
eprintln!(
"Leases snapshot the LOCAL vault; project '{project}' uses {}.",
be.label()
);
process::exit(1);
}
let agent = if any_agent { None } else { agent.or_else(registry::resolve_agent) };
let key_list = if names.len() <= 4 {
names.join(", ")
} else {
format!("{} +{} more", names[..4].join(", "), names.len() - 4)
};
let span = match &win {
Some(w) => format!("inside {}", w.describe()),
None => format!("for {}", lease::human_secs(ttl_secs)),
};
let prompt = format!(
"Lease {key_list} for “{project}” — tap-free reads {span}{}",
match &agent {
Some(a) => format!(", bound to agent {a}"),
None => ", any local caller".to_string(),
}
);
let view = unlock_view(&prompt);
let mut entries: Vec<(String, Zeroizing<Vec<u8>>)> = Vec::new();
let mut absent: Vec<&str> = Vec::new();
for name in &names {
match view.get_project(&project, name) {
Some(v) => entries.push((name.clone(), v)),
None => absent.push(name.as_str()),
}
}
if !absent.is_empty() {
eprintln!("warning: not in vault, not leased: {}", absent.join(", "));
}
if entries.is_empty() {
eprintln!("Error: none of '{project}'s declared keys are in the vault.");
process::exit(1);
}
let stored = entries.len();
let expires =
lease::create(&dir, ks, &project, &entries, ttl_secs, agent.as_deref(), win.as_ref(), now)
.unwrap_or_else(|e| {
eprintln!("Error: {e}");
process::exit(1);
});
if let Some(a) = &agent {
if let Ok(mut reg) = view.registry_load(&dir) {
if reg.grant_for(a, &project, registry::now()).is_none() {
reg.set_grant(a, &project, registry::Scope::Session { expires });
if let Err(e) = view.registry_save(®, &dir) {
eprintln!("(warning: could not record session grant: {e})");
}
}
}
}
eprintln!(
"Leased {stored} secret(s) for '{project}' — tap-free {}{}.",
match &win {
Some(w) => format!("inside {} (outer bound {})",
w.describe(), lease::human_secs(ttl_secs)),
None => format!("for {}", lease::human_secs(ttl_secs)),
},
match &agent {
Some(a) => format!(", bound to agent '{a}'"),
None => ", any local caller".to_string(),
}
);
eprintln!(
"Reads: secrets get KEY -p {project} (or: secrets exec {project} -- cmd)"
);
eprintln!("Ends: at expiry, or now via `secrets lease revoke {project}`.");
}
LeaseCmd::Get { project, key } => {
if !is_valid_key(&key) {
eprintln!("Invalid key: '{key}'");
process::exit(1);
}
match lease::read(
&dir,
ks,
&project,
&key,
registry::resolve_agent().as_deref(),
lease::now(),
) {
lease::ReadOutcome::Served(value) => {
io::Write::write_all(&mut io::stdout(), &value).ok();
}
lease::ReadOutcome::NoLease => {
eprintln!(
"No lease for '{project}'. Create one (one tap): secrets lease create {project}"
);
process::exit(1);
}
lease::ReadOutcome::Expired => {
eprintln!("Lease for '{project}' has expired — it was just destroyed.");
process::exit(1);
}
lease::ReadOutcome::Denied => {
eprintln!("Lease for '{project}' is bound to a different agent.");
process::exit(1);
}
lease::ReadOutcome::NotInLease => {
eprintln!("'{key}' is not in the lease for '{project}' (only the keys approved at create are served).");
process::exit(1);
}
lease::ReadOutcome::Error(e) => {
eprintln!("Error: {e}");
process::exit(1);
}
}
}
LeaseCmd::Status { json } => {
let rows = lease::status(&dir, ks, lease::now());
if json {
let t = lease::now();
let leases: Vec<serde_json::Value> = rows
.iter()
.map(|(project, state)| match state {
lease::LeaseState::Active { expires, agent, keys, window } => {
let mut row = serde_json::json!({
"project": project,
"state": "active",
"keys": keys,
"expires_in_secs": expires.saturating_sub(t),
"agent": agent,
});
if let Some(w) = window {
row["window"] = serde_json::json!({
"schedule": w.describe(),
});
match w.state(t) {
Ok(window::State::Open { closes }) => {
row["window"]["state"] = "open".into();
row["window"]["closes_in_secs"] =
((closes - t as i64).max(0)).into();
}
Ok(window::State::Closed { next_open }) => {
row["window"]["state"] = "closed".into();
if let Some(n) = next_open {
row["window"]["opens_in_secs"] =
((n - t as i64).max(0)).into();
}
}
Err(e) => {
row["window"]["state"] = "unevaluable".into();
row["window"]["error"] = e.into();
}
}
}
row
}
lease::LeaseState::ExpiredPurged => serde_json::json!({
"project": project, "state": "expired_purged",
}),
lease::LeaseState::OrphanPurged => serde_json::json!({
"project": project, "state": "orphan_purged",
}),
lease::LeaseState::Corrupt(e) => serde_json::json!({
"project": project, "state": "corrupt", "error": e,
}),
})
.collect();
println!("{}", serde_json::json!({ "leases": leases }));
return;
}
if rows.is_empty() {
eprintln!("No leases.");
return;
}
let t = lease::now();
for (project, state) in rows {
match state {
lease::LeaseState::Active { expires, agent, keys, window } => {
let sched = match &window {
None => format!("{} left", lease::human_secs(expires.saturating_sub(t))),
Some(w) => {
let phase = match w.state(t) {
Ok(window::State::Open { closes }) => format!(
"OPEN, closes in {}",
lease::human_secs((closes - t as i64).max(0) as u64)
),
Ok(window::State::Closed { next_open: Some(n) }) => format!(
"closed, opens in {}",
lease::human_secs((n - t as i64).max(0) as u64)
),
Ok(window::State::Closed { next_open: None }) =>
"closed, never opens again".to_string(),
Err(e) => format!("window unevaluable ({e}) — reads refused"),
};
format!("window {} — {}", w.describe(), phase)
}
};
println!(
"{project}: {} key(s) [{}] — {sched}{}",
keys.len(),
keys.join(", "),
match agent {
Some(a) => format!(", bound to '{a}'"),
None => ", any local caller".to_string(),
}
);
}
lease::LeaseState::ExpiredPurged => {
println!("{project}: expired — destroyed");
}
lease::LeaseState::OrphanPurged => {
println!("{project}: orphaned lease artifact — removed");
}
lease::LeaseState::Corrupt(e) => {
println!("{project}: CORRUPT — {e} (fix: secrets lease revoke {project})");
}
}
}
}
LeaseCmd::Revoke { project } => {
if lease::revoke(&dir, ks, &project) {
eprintln!(
"Lease for '{project}' destroyed (key crypto-erased, file removed)."
);
} else {
eprintln!("No lease for '{project}'.");
process::exit(1);
}
}
}
}
Commands::Migrate => {
let dir = secrets_dir();
let Some(data) = read_vault_bytes() else {
eprintln!("No vault to migrate — a new vault is created as v2 automatically.");
process::exit(1);
};
if is_v2(&data) {
let reg_path = registry::Registry::path(&dir);
let reg_is_v1 = fs::read(®_path)
.map(|d| d.starts_with(b"QVLT"))
.unwrap_or(false);
if !reg_is_v1 {
eprintln!("Vault is already QVLT v2 — nothing to migrate.");
return;
}
let salt = v2_salt(&data).unwrap_or_else(|e| {
eprintln!("Error: {e}");
process::exit(1);
});
let pass = get_passphrase_prompted("Migrate the grant registry to v2");
let reg = registry::Registry::load_v1(&dir, &pass).unwrap_or_else(|e| {
eprintln!("Error: {e}");
process::exit(1);
});
let master = MasterSecret::derive(&pass, &salt);
drop(pass);
if VaultReader::open(data, &master).is_err() {
eprintln!("Error: wrong passphrase");
process::exit(1);
}
reg.save_raw(&dir, &master.registry_key()).unwrap_or_else(|e| {
eprintln!("Error: {e}");
process::exit(1);
});
eprintln!("Registry converted to the v2 raw-key container.");
return;
}
if !is_v1(&data) {
eprintln!("Error: vault file is not a recognized QVLT format");
process::exit(1);
}
let pass = get_passphrase_prompted("Migrate vault to v2 (per-entry encryption)");
let vault = match Vault::decrypt(&data, &pass) {
Ok(v) => v,
Err(VaultError::DecryptionFailed) => {
eprintln!("Error: wrong passphrase");
process::exit(1);
}
Err(e) => {
eprintln!("Error: {e}");
process::exit(1);
}
};
let reg_path = registry::Registry::path(&dir);
let legacy_reg = if fs::read(®_path)
.map(|d| d.starts_with(b"QVLT"))
.unwrap_or(false)
{
Some(registry::Registry::load_v1(&dir, &pass).unwrap_or_else(|e| {
eprintln!("Error: {e}");
process::exit(1);
}))
} else {
None
};
let master = MasterSecret::derive(&pass, &random_salt());
drop(pass);
let entries: Vec<(String, Zeroizing<Vec<u8>>)> = vault
.iter()
.map(|(k, v)| (k.to_string(), Zeroizing::new(v.as_bytes().to_vec())))
.collect();
let count = entries.len();
let out = v2_create(&master, &entries).unwrap_or_else(|e| {
eprintln!("Error encrypting v2 vault: {e}");
process::exit(1);
});
let backup = vault_path().with_extension("qvlt.v1.bak");
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
if let Ok(mut f) = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&backup)
{
io::Write::write_all(&mut f, &data).ok();
}
}
#[cfg(not(unix))]
fs::write(&backup, &data).ok();
let names: Vec<String> = vault.keys().map(String::from).collect();
write_v2_vault(&out, &names);
if let Some(reg) = legacy_reg {
reg.save_raw(&dir, &master.registry_key()).unwrap_or_else(|e| {
eprintln!("Error converting registry: {e}");
process::exit(1);
});
}
session::end(&dir);
eprintln!("Migrated {count} secret(s) to QVLT v2 (per-entry encryption).");
eprintln!("Reads now decrypt ONLY the requested key; writes splice without reading.");
eprintln!("v1 backup kept at {} — delete it once confident:", backup.display());
eprintln!(" trash {}", backup.display());
}
Commands::Rekey => {
let dir = secrets_dir();
let Some(data) = read_vault_bytes() else {
eprintln!("No vault to rekey.");
process::exit(1);
};
if is_v1(&data) {
migrate_hint();
eprintln!("`secrets rekey` requires a v2 vault.");
process::exit(1);
}
let salt = v2_salt(&data).unwrap_or_else(|e| {
eprintln!("Error: {e}");
process::exit(1);
});
let pass = get_passphrase_prompted("Rekey your secrets vault (fresh salt)");
let old_master = MasterSecret::derive(&pass, &salt);
let new_master = MasterSecret::derive(&pass, &random_salt());
drop(pass);
let reader = open_reader(data, &old_master);
let vault = reader.decrypt_all(&old_master).unwrap_or_else(|e| {
eprintln!("Error: {e}");
process::exit(1);
});
let reg = registry::Registry::load_raw(&dir, &old_master.registry_key())
.unwrap_or_default();
if session::socket_path(&dir).exists() {
session::end(&dir);
eprintln!("(ended the live session broker — its keys predate the rekey)");
}
if let Ok(ks) = lease::keystore_from_env() {
let n = lease::revoke_all(&dir, ks);
if n > 0 {
eprintln!("(revoked {n} live lease(s) — they served pre-rekey values tap-free)");
}
}
let entries: Vec<(String, Zeroizing<Vec<u8>>)> = vault
.iter()
.map(|(k, v)| (k.to_string(), Zeroizing::new(v.as_bytes().to_vec())))
.collect();
let out = v2_create(&new_master, &entries).unwrap_or_else(|e| {
eprintln!("Error encrypting: {e}");
process::exit(1);
});
let names: Vec<String> = vault.keys().map(String::from).collect();
write_v2_vault(&out, &names);
reg.save_raw(&dir, &new_master.registry_key()).unwrap_or_else(|e| {
eprintln!("Error re-encrypting registry: {e}");
process::exit(1);
});
eprintln!("Rekeyed {} secret(s) under a fresh salt.", entries.len());
let mut stale: Vec<PathBuf> = Vec::new();
for cand in [vault_path().with_extension("qvlt.v1.bak"), dir.join(".vault.qvlt.tmp")] {
if cand.exists() {
stale.push(cand);
}
}
if !stale.is_empty() {
eprintln!("⚠️ STALE CIPHERTEXT still on disk — the old key material opens these:");
for s in &stale {
eprintln!(" {}", s.display());
}
eprintln!(" Delete them (trash <file>) or this rekey revokes nothing.");
}
}
Commands::Inbox { sub } => {
let dir = secrets_dir();
match sub {
InboxCmd::Init => match inbox::ensure_recipient(&dir) {
Ok(_) => eprintln!(
"Inbox ready — recipient at {} (identity in the Keychain).",
inbox::pub_path(&dir).display()
),
Err(e) => {
eprintln!("Error: {e}");
process::exit(1);
}
},
InboxCmd::List => {
let entries = inbox::read_entries(&dir).unwrap_or_default();
if entries.is_empty() {
eprintln!("Inbox empty — nothing pending.");
} else {
let index = read_index();
eprintln!("{} pending (review, then `secrets inbox merge`):", entries.len());
for e in &entries {
let tag = if index.iter().any(|k| k == &e.name) {
"⚠ OVERWRITE"
} else {
"new"
};
println!(" {} [{tag}]", e.name);
}
}
}
InboxCmd::Drop { name } => match inbox::drop_entry(&dir, &name) {
Ok(true) => eprintln!("Dropped pending '{name}'."),
Ok(false) => {
eprintln!("No pending entry named '{name}'.");
process::exit(1);
}
Err(e) => {
eprintln!("Error: {e}");
process::exit(1);
}
},
InboxCmd::Merge => {
let entries = match inbox::read_entries(&dir) {
Ok(e) => e,
Err(e) => {
eprintln!("Error: {e}");
process::exit(1);
}
};
if entries.is_empty() {
eprintln!("Inbox empty — nothing to merge.");
return;
}
let vals = match keychain::read_accounts(
&["inbox-identity", "vault-master"],
is_strict_mode(),
) {
Ok(v) => v,
Err(e) => {
eprintln!("Keychain error: {e}");
process::exit(1);
}
};
let identity_str = match vals.first().cloned().flatten() {
Some(s) => s,
None => {
eprintln!("Inbox identity not found — run `secrets inbox init` first.");
process::exit(1);
}
};
let identity: age::x25519::Identity = match identity_str.parse() {
Ok(i) => i,
Err(e) => {
eprintln!("Corrupt inbox identity: {e}");
process::exit(1);
}
};
let master = match env::var("SECRETS_PASSPHRASE")
.ok()
.or_else(|| vals.get(1).cloned().flatten())
{
Some(m) => Zeroizing::new(m),
None => {
eprintln!("Vault is locked — run `secrets unlock` first.");
process::exit(1);
}
};
let mut view = view_from_passphrase(master);
let mut new_keys: Vec<String> = Vec::new();
let mut overwrites: Vec<String> = Vec::new();
let mut failed: Vec<(String, String)> = Vec::new();
let mut upserts: Vec<(String, Zeroizing<Vec<u8>>)> = Vec::new();
for e in &entries {
match inbox::open(&identity, &e.sealed) {
Ok(value) => {
if view.contains(&e.name) {
overwrites.push(e.name.clone());
} else {
new_keys.push(e.name.clone());
}
upserts.push((e.name.clone(), Zeroizing::new(value.into_bytes())));
}
Err(err) => failed.push((e.name.clone(), err)),
}
}
if new_keys.is_empty() && overwrites.is_empty() {
eprintln!("Nothing merged — all entries failed to open:");
for (n, err) in &failed {
eprintln!(" ✗ {n}: {err}");
}
process::exit(1);
}
view.write(upserts, vec![]);
use std::collections::HashSet;
let merged: HashSet<&String> =
new_keys.iter().chain(overwrites.iter()).collect();
let leftover: Vec<&inbox::Entry> =
entries.iter().filter(|e| !merged.contains(&e.name)).collect();
if let Err(e) = inbox::rewrite(&dir, &leftover) {
eprintln!("(warning: could not rewrite inbox: {e})");
}
eprintln!(
"Merged {} new + {} overwrite into the vault:",
new_keys.len(),
overwrites.len()
);
for n in &new_keys {
eprintln!(" + {n} (new)");
}
for n in &overwrites {
eprintln!(" ⚠ {n} (OVERWRITE)");
}
for (n, err) in &failed {
eprintln!(" ✗ {n}: {err} (left in inbox)");
}
}
}
}
}
}