use std::{
collections::HashMap,
ffi::OsString,
fs,
io::BufRead as _,
path::{Path, PathBuf},
sync::Arc,
};
#[cfg(target_family = "unix")]
use std::os::unix::fs::DirBuilderExt as _;
use anyhow::{Result, anyhow};
use bincode_next::{config::standard, decode_from_slice, encode_to_vec};
use clap::Parser as _;
use dialoguer::Password;
use libmoshpit::{
AgentIdentityInfo, AgentRequest, AgentResponse, fingerprint, load_identity_key, load_public_key,
};
use tokio::{
io::{AsyncReadExt as _, AsyncWriteExt as _},
net::{UnixListener, UnixStream},
sync::Mutex,
};
use tracing::{error, info, warn};
use zeroize::Zeroize as _;
use crate::{
cli::{Cli, Commands, ShellKind},
config::AgentConfig,
unlock::{UnlockBackend, passphrase::PassphraseBackend},
vault::Vault,
};
#[cfg(feature = "fido2")]
use crate::unlock::fido2::Fido2Backend;
#[cfg(feature = "fprintd")]
use crate::unlock::fprintd::FprintdBackend;
#[cfg(feature = "macos-keychain")]
use crate::unlock::macos_keychain::MacosKeychainBackend;
#[cfg(feature = "secret-service")]
use crate::unlock::secret_service::SecretServiceBackend;
#[cfg(feature = "ssh-agent-piggyback")]
use crate::unlock::ssh_agent::SshAgentBackend;
#[cfg(feature = "systemd-creds")]
use crate::unlock::systemd_creds::SystemdCredsBackend;
#[cfg(feature = "tpm")]
use crate::unlock::tpm::TpmBackend;
#[derive(Clone)]
struct Identity {
full_pub_key_bytes: Vec<u8>,
algorithm: String,
fingerprint: String,
private_key: Vec<u8>,
}
impl Drop for Identity {
fn drop(&mut self) {
self.private_key.zeroize();
}
}
type IdentityMap = Arc<Mutex<HashMap<String, Identity>>>;
#[must_use]
fn new_identity_map() -> IdentityMap {
Arc::new(Mutex::new(HashMap::new()))
}
async fn load_key_into_map(
map: &IdentityMap,
key_path: &str,
passphrase: Option<&str>,
vault: &Arc<Mutex<Option<Vault>>>,
vault_path: &Path,
master_passphrase: &str,
) -> Result<AgentIdentityInfo> {
let priv_path = PathBuf::from(key_path);
let mut pub_path = priv_path.clone();
let _ = pub_path.set_extension("pub");
let (full_pub_key_bytes, pub_key_bytes) = load_public_key(&pub_path)?;
let identity_key = load_identity_key(&priv_path, passphrase)?;
let fp = fingerprint(&pub_key_bytes)?;
let fp_short = fp.split_whitespace().next().unwrap_or(&fp).to_string();
let comment = fp.split_whitespace().nth(1).unwrap_or("").to_string();
let identity = Identity {
full_pub_key_bytes,
algorithm: identity_key.key_algorithm().clone(),
fingerprint: fp_short.clone(),
private_key: identity_key.private_key().clone(),
};
drop(map.lock().await.insert(fp_short.clone(), identity.clone()));
{
let mut vault_guard = vault.lock().await;
let v = vault_guard.get_or_insert_with(Vault::new);
v.upsert(key_path.to_string(), passphrase.unwrap_or("").to_string());
if master_passphrase.is_empty() {
v.save_plaintext(vault_path)?;
} else {
v.save_encrypted(vault_path, master_passphrase)?;
}
}
Ok(AgentIdentityInfo {
algorithm: identity.algorithm.clone(),
fingerprint: fp_short,
comment,
})
}
async fn handle_connection(
mut stream: UnixStream,
identities: IdentityMap,
vault: Arc<Mutex<Option<Vault>>>,
vault_path: PathBuf,
master_passphrase: Arc<Mutex<String>>,
socket_path: PathBuf,
lock_path: PathBuf,
locked: Arc<Mutex<bool>>,
) {
while let Ok(raw_len) = stream.read_u32().await {
let req_len = raw_len as usize;
if req_len == 0 {
break;
}
let mut buf = vec![0u8; req_len];
if stream.read_exact(&mut buf).await.is_err() {
break;
}
let request = match decode_from_slice::<AgentRequest, _>(&buf, standard()) {
Ok((r, _)) => r,
Err(e) => {
error!("failed to decode agent request: {e}");
break;
}
};
let is_shutdown = matches!(request, AgentRequest::Shutdown);
let response = dispatch_request(
request,
&identities,
&vault,
&vault_path,
&master_passphrase,
&locked,
)
.await;
let encoded = match encode_to_vec(&response, standard()) {
Ok(b) => b,
Err(e) => {
error!("failed to encode agent response: {e}");
break;
}
};
let Ok(len) = u32::try_from(encoded.len()) else {
break;
};
if stream.write_all(&len.to_be_bytes()).await.is_err()
|| stream.write_all(&encoded).await.is_err()
|| stream.flush().await.is_err()
{
break;
}
if is_shutdown {
drop(fs::remove_file(&socket_path));
drop(fs::remove_file(&lock_path));
std::process::exit(0);
}
}
}
fn sign_data(id: &Identity, data: &[u8]) -> AgentResponse {
#[cfg(feature = "unstable")]
{
use aws_lc_rs::unstable::signature::{
ML_DSA_44_SIGNING, ML_DSA_65_SIGNING, ML_DSA_87_SIGNING, PqdsaKeyPair,
};
use libmoshpit::KEY_ALGORITHM_ML_DSA_44;
use libmoshpit::KEY_ALGORITHM_ML_DSA_65;
use libmoshpit::KEY_ALGORITHM_ML_DSA_87;
let signing_alg = match id.algorithm.as_str() {
KEY_ALGORITHM_ML_DSA_44 => &ML_DSA_44_SIGNING,
KEY_ALGORITHM_ML_DSA_65 => &ML_DSA_65_SIGNING,
KEY_ALGORITHM_ML_DSA_87 => &ML_DSA_87_SIGNING,
_ => {
return AgentResponse::Error(format!(
"algorithm {} does not support signing",
id.algorithm
));
}
};
match PqdsaKeyPair::from_raw_private_key(signing_alg, &id.private_key) {
Ok(kp) => {
let mut sig = vec![0u8; signing_alg.signature_len()];
match kp.sign(data, &mut sig) {
Ok(len) => {
sig.truncate(len);
AgentResponse::Signature(sig)
}
Err(e) => AgentResponse::Error(format!("signing failed: {e}")),
}
}
Err(e) => AgentResponse::Error(format!("key load failed: {e}")),
}
}
#[cfg(not(feature = "unstable"))]
{
let _ = data;
AgentResponse::Error(format!(
"algorithm {} does not support signing (build without unstable feature)",
id.algorithm
))
}
}
#[cfg_attr(nightly, allow(clippy::too_many_lines))]
async fn dispatch_request(
request: AgentRequest,
identities: &IdentityMap,
vault: &Arc<Mutex<Option<Vault>>>,
vault_path: &Path,
master_passphrase: &Arc<Mutex<String>>,
locked: &Arc<Mutex<bool>>,
) -> AgentResponse {
match request {
AgentRequest::ListIdentities => {
let map = identities.lock().await;
let ids: Vec<AgentIdentityInfo> = map
.values()
.map(|id| AgentIdentityInfo {
algorithm: id.algorithm.clone(),
fingerprint: id.fingerprint.clone(),
comment: String::new(),
})
.collect();
AgentResponse::Identities(ids)
}
AgentRequest::ListSupportedIdentities {
supported_algorithms,
} => {
let map = identities.lock().await;
let ids: Vec<AgentIdentityInfo> = map
.values()
.filter(|id| supported_algorithms.contains(&id.algorithm))
.map(|id| AgentIdentityInfo {
algorithm: id.algorithm.clone(),
fingerprint: id.fingerprint.clone(),
comment: String::new(),
})
.collect();
AgentResponse::Identities(ids)
}
AgentRequest::GetPublicKey(fp) => {
let map = identities.lock().await;
match map.get(&fp) {
Some(id) => AgentResponse::PublicKey(id.full_pub_key_bytes.clone()),
None => AgentResponse::Error(format!("identity not found: {fp}")),
}
}
AgentRequest::Sign {
fingerprint: fp,
data,
} => {
let map = identities.lock().await;
match map.get(&fp) {
Some(id) => sign_data(id, &data),
None => AgentResponse::Error(format!("identity not found: {fp}")),
}
}
AgentRequest::AddIdentity {
key_path,
passphrase,
} => {
let mp = master_passphrase.lock().await.clone();
match load_key_into_map(
identities,
&key_path,
passphrase.as_deref(),
vault,
vault_path,
&mp,
)
.await
{
Ok(_) => AgentResponse::Ok,
Err(e) => AgentResponse::Error(e.to_string()),
}
}
AgentRequest::RemoveIdentity(fp) => {
let removed = identities.lock().await.remove(&fp).is_some();
if removed {
let mut vault_guard = vault.lock().await;
if let Some(v) = vault_guard.as_mut() {
let mp = master_passphrase.lock().await.clone();
if !mp.is_empty() {
drop(v.save_encrypted(vault_path, &mp));
}
}
AgentResponse::Ok
} else {
AgentResponse::Error(format!("identity not found: {fp}"))
}
}
AgentRequest::RemoveAllIdentities => {
identities.lock().await.clear();
AgentResponse::Ok
}
AgentRequest::Lock => {
*locked.lock().await = true;
let mut map = identities.lock().await;
for id in map.values_mut() {
id.private_key.zeroize();
}
map.clear();
info!("agent locked");
AgentResponse::Ok
}
AgentRequest::Unlock(passphrase) => {
let mut mp = master_passphrase.lock().await;
(*mp).clone_from(&passphrase);
drop(mp);
match reload_from_vault(identities, vault, vault_path, &passphrase).await {
Ok(n) => {
*locked.lock().await = false;
info!("agent unlocked, {n} identities loaded");
AgentResponse::Ok
}
Err(e) => AgentResponse::Error(e.to_string()),
}
}
AgentRequest::Status => {
let is_locked = *locked.lock().await;
let map = identities.lock().await;
let identities = map
.values()
.map(|id| AgentIdentityInfo {
algorithm: id.algorithm.clone(),
fingerprint: id.fingerprint.clone(),
comment: String::new(),
})
.collect();
AgentResponse::AgentStatus {
locked: is_locked,
identities,
}
}
AgentRequest::Shutdown => AgentResponse::Ok,
}
}
async fn reload_from_vault(
identities: &IdentityMap,
vault: &Arc<Mutex<Option<Vault>>>,
vault_path: &Path,
master_passphrase: &str,
) -> Result<usize> {
let loaded_vault = if vault_path.exists() {
Vault::load_encrypted(vault_path, master_passphrase)?
} else {
return Ok(0);
};
let mut map = identities.lock().await;
map.clear();
let mut count = 0;
for entry in loaded_vault.entries() {
let priv_path = PathBuf::from(&entry.key_path);
let mut pub_path = priv_path.clone();
let _ = pub_path.set_extension("pub");
let passphrase_opt = if entry.passphrase.is_empty() {
None
} else {
Some(entry.passphrase.as_str())
};
match load_public_key(&pub_path) {
Ok((full_pub_key_bytes, pub_key_bytes)) => {
match load_identity_key(&priv_path, passphrase_opt) {
Ok(identity_key) => {
let fp_full = fingerprint(&pub_key_bytes).unwrap_or_default();
let fp = fp_full
.split_whitespace()
.next()
.unwrap_or(&fp_full)
.to_string();
drop(map.insert(
fp.clone(),
Identity {
full_pub_key_bytes,
algorithm: identity_key.key_algorithm().clone(),
fingerprint: fp,
private_key: identity_key.private_key().clone(),
},
));
count += 1;
}
Err(e) => warn!("skipping {}: {e}", entry.key_path),
}
}
Err(e) => warn!("skipping {}: {e}", entry.key_path),
}
}
*vault.lock().await = Some(loaded_vault);
Ok(count)
}
async fn send_to_agent(socket_path: &Path, request: AgentRequest) -> Result<AgentResponse> {
use libmoshpit::AgentClient;
let client = AgentClient::new(socket_path.to_path_buf());
client.send(&request).await
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn read_passphrase_stdin() -> Result<String> {
let mut line = String::new();
let _n = std::io::stdin().lock().read_line(&mut line)?;
Ok(line.trim_end_matches(['\n', '\r']).to_string())
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn read_key_passphrase(from_stdin: bool, key_path: &str) -> Result<Option<String>> {
if from_stdin {
let mut line = String::new();
let _n = std::io::stdin().lock().read_line(&mut line)?;
let trimmed = line.trim_end_matches(['\n', '\r']).to_string();
Ok(if trimmed.is_empty() {
None
} else {
Some(trimmed)
})
} else {
let pw = Password::new()
.with_prompt(format!("Passphrase for {key_path}"))
.allow_empty_password(true)
.interact()?;
Ok(if pw.is_empty() { None } else { Some(pw) })
}
}
#[cfg(not(feature = "unstable"))]
const PREFERENCE_ORDER: &[&str] = &["P384", "P256", "X25519"];
#[cfg(feature = "unstable")]
const PREFERENCE_ORDER: &[&str] = &[
"ML-DSA-87",
"ML-DSA-65",
"ML-DSA-44",
"P384",
"P256",
"X25519",
];
fn best_identity(ids: &[AgentIdentityInfo]) -> &AgentIdentityInfo {
for alg in PREFERENCE_ORDER {
if let Some(id) = ids.iter().find(|id| id.algorithm == *alg) {
return id;
}
}
&ids[0]
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn print_selection_hint(ids: &[AgentIdentityInfo], command: &str) {
let best = best_identity(ids);
let hierarchy = PREFERENCE_ORDER.join(" > ");
eprintln!(
"note: {} keys loaded — {} ({}) will be used (strongest available).",
ids.len(),
best.fingerprint,
best.algorithm
);
eprintln!(" preference: {hierarchy}");
eprintln!(" pass --no-hint to {command} to suppress");
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg_attr(nightly, allow(clippy::too_many_lines))]
pub(crate) async fn run<I, T>(args: Option<I>) -> Result<()>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
let cli = if let Some(args) = args {
Cli::try_parse_from(args)?
} else {
Cli::try_parse()?
};
match cli.command() {
Commands::Start {
socket,
vault,
foreground,
backend,
passphrase_stdin,
shell,
passphrase_pipe,
} => {
run_daemon(
socket.as_deref(),
vault.as_deref(),
*foreground,
backend.clone(),
*passphrase_stdin,
*shell,
*passphrase_pipe,
)
.await
}
Commands::AddKey {
key_path,
passphrase_stdin,
no_hint,
} => {
let passphrase = read_key_passphrase(*passphrase_stdin, key_path)?;
let socket_path = socket_from_env()?;
let resp = send_to_agent(
&socket_path,
AgentRequest::AddIdentity {
key_path: key_path.clone(),
passphrase,
},
)
.await?;
match resp {
AgentResponse::Ok => {
println!("Identity added.");
if !no_hint {
let list_resp =
send_to_agent(&socket_path, AgentRequest::ListIdentities).await?;
if let AgentResponse::Identities(ids) = list_resp
&& ids.len() > 1
{
print_selection_hint(&ids, "add-key");
}
}
}
AgentResponse::Error(e) => return Err(anyhow!("agent error: {e}")),
_ => return Err(anyhow!("unexpected response")),
}
Ok(())
}
Commands::List { no_hint } => {
let socket_path = socket_from_env()?;
let resp = send_to_agent(&socket_path, AgentRequest::ListIdentities).await?;
match resp {
AgentResponse::Identities(ids) => {
if ids.is_empty() {
println!("No identities.");
} else {
for id in &ids {
println!("{} {} {}", id.fingerprint, id.algorithm, id.comment);
}
if !no_hint && ids.len() > 1 {
print_selection_hint(&ids, "list");
}
}
}
AgentResponse::Error(e) => return Err(anyhow!("agent error: {e}")),
_ => return Err(anyhow!("unexpected response")),
}
Ok(())
}
Commands::RemoveKey { fingerprint } => {
let socket_path = socket_from_env()?;
let resp = send_to_agent(
&socket_path,
AgentRequest::RemoveIdentity(fingerprint.clone()),
)
.await?;
match resp {
AgentResponse::Ok => println!("Identity removed."),
AgentResponse::Error(e) => return Err(anyhow!("agent error: {e}")),
_ => return Err(anyhow!("unexpected response")),
}
Ok(())
}
Commands::Lock => {
let socket_path = socket_from_env()?;
let resp = send_to_agent(&socket_path, AgentRequest::Lock).await?;
match resp {
AgentResponse::Ok => println!("Agent locked."),
AgentResponse::Error(e) => return Err(anyhow!("agent error: {e}")),
_ => return Err(anyhow!("unexpected response")),
}
Ok(())
}
Commands::Unlock => {
let socket_path = socket_from_env()?;
let passphrase = Password::new()
.with_prompt("Master passphrase")
.interact()?;
let resp = send_to_agent(&socket_path, AgentRequest::Unlock(passphrase)).await?;
match resp {
AgentResponse::Ok => println!("Agent unlocked."),
AgentResponse::Error(e) => return Err(anyhow!("agent error: {e}")),
_ => return Err(anyhow!("unexpected response")),
}
Ok(())
}
Commands::Status => {
let socket_path = std::env::var("MOSHPIT_AGENT_SOCK")
.map(PathBuf::from)
.unwrap_or_else(|_| crate::config::default_socket_path());
let client = libmoshpit::AgentClient::new(socket_path);
match client.status().await {
Err(_) => println!("stopped"),
Ok((is_locked, ids)) => {
if is_locked {
println!("running (locked)");
} else if ids.is_empty() {
println!("running (no keys)");
} else {
println!("running");
for id in &ids {
println!(" {} {} {}", id.fingerprint, id.algorithm, id.comment);
}
}
}
}
Ok(())
}
Commands::Stop { socket, shell } => {
let socket_path = socket
.as_deref()
.map(PathBuf::from)
.map_or_else(socket_from_env, Ok)?;
drop(send_to_agent(&socket_path, AgentRequest::Shutdown).await);
print_unset_socket_env(*shell);
Ok(())
}
}
}
fn socket_from_env() -> Result<PathBuf> {
std::env::var("MOSHPIT_AGENT_SOCK")
.map(PathBuf::from)
.map_err(|_| anyhow!("MOSHPIT_AGENT_SOCK not set — is the agent running?"))
}
fn unlock_backend(config: &AgentConfig) -> Box<dyn UnlockBackend> {
let _ = &config.backend;
#[cfg(feature = "fido2")]
if config.backend == "fido2" {
return Box::new(Fido2Backend {
state_path: config.fido2_state_path.clone(),
});
}
#[cfg(feature = "systemd-creds")]
if config.backend == "systemd-creds" {
return Box::new(SystemdCredsBackend);
}
#[cfg(feature = "ssh-agent-piggyback")]
if config.backend == "ssh-agent-piggyback" {
return Box::new(SshAgentBackend);
}
#[cfg(feature = "tpm")]
if config.backend == "tpm" {
return Box::new(TpmBackend);
}
#[cfg(feature = "fprintd")]
if config.backend == "fprintd" {
return Box::new(FprintdBackend);
}
#[cfg(feature = "secret-service")]
if config.backend == "secret-service" {
return Box::new(SecretServiceBackend);
}
#[cfg(feature = "macos-keychain")]
if config.backend == "macos-keychain" {
return Box::new(MacosKeychainBackend);
}
Box::new(PassphraseBackend)
}
fn format_socket_env(path: &Path, shell: ShellKind) -> String {
match shell {
ShellKind::Fish => format!("set -Ux MOSHPIT_AGENT_SOCK {}", path.display()),
ShellKind::Bash => format!(
"MOSHPIT_AGENT_SOCK={}; export MOSHPIT_AGENT_SOCK",
path.display()
),
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn print_socket_env(path: &Path, shell: ShellKind) {
println!("{}", format_socket_env(path, shell));
}
fn format_unset_socket_env(shell: ShellKind) -> String {
match shell {
ShellKind::Fish => "set -e MOSHPIT_AGENT_SOCK".to_string(),
ShellKind::Bash => "unset MOSHPIT_AGENT_SOCK".to_string(),
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn print_unset_socket_env(shell: ShellKind) {
println!("{}", format_unset_socket_env(shell));
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn is_process_alive(pid: u32) -> bool {
#[cfg(target_family = "unix")]
{
use nix::{errno::Errno, sys::signal::kill, unistd::Pid};
matches!(
kill(Pid::from_raw(pid as i32), None),
Ok(()) | Err(Errno::EPERM)
)
}
#[cfg(not(target_family = "unix"))]
{
let _ = pid;
true }
}
async fn check_not_already_running(lock_path: &Path, socket_path: &Path) -> Result<()> {
if lock_path.exists() {
let pid_str = fs::read_to_string(lock_path).unwrap_or_default();
let pid: u32 = pid_str.trim().parse().unwrap_or(0);
if pid > 0 && is_process_alive(pid) {
if socket_path.exists() && UnixStream::connect(socket_path).await.is_ok() {
return Err(anyhow!(
"agent is already running (pid {pid}, socket: {})\n To stop it: mpa stop",
socket_path.display()
));
}
}
drop(fs::remove_file(lock_path));
} else if socket_path.exists() && UnixStream::connect(socket_path).await.is_ok() {
return Err(anyhow!(
"agent is already running (socket: {})\n To stop it: mpa stop",
socket_path.display()
));
}
Ok(())
}
fn spawn_daemon_child(
socket_override: Option<&str>,
vault_override: Option<&str>,
backend: &str,
shell: ShellKind,
master_passphrase: &str,
) -> Result<()> {
let exe = std::env::current_exe()?;
let shell_str = match shell {
ShellKind::Fish => "fish",
ShellKind::Bash => "bash",
};
let mut cmd = std::process::Command::new(exe);
let _ = cmd
.arg("start")
.arg("--foreground")
.arg("--shell")
.arg(shell_str)
.arg("--backend")
.arg(backend)
.arg("--passphrase-pipe");
if let Some(s) = socket_override {
let _ = cmd.arg("--socket").arg(s);
}
if let Some(v) = vault_override {
let _ = cmd.arg("--vault").arg(v);
}
let _ = cmd
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
#[cfg(target_family = "unix")]
{
use std::os::unix::process::CommandExt as _;
let _ = cmd.process_group(0);
}
let mut child = cmd.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
use std::io::Write as _;
drop(writeln!(stdin, "{master_passphrase}"));
}
Ok(())
}
#[cfg_attr(coverage_nightly, coverage(off))]
async fn run_daemon(
socket_override: Option<&str>,
vault_override: Option<&str>,
foreground: bool,
backend: String,
passphrase_stdin: bool,
shell: ShellKind,
passphrase_pipe: bool,
) -> Result<()> {
let config = AgentConfig::resolve(socket_override, vault_override, foreground, backend, shell);
if let Some(parent) = config.socket_path.parent() {
#[cfg(target_family = "unix")]
fs::DirBuilder::new()
.mode(0o700)
.recursive(true)
.create(parent)?;
#[cfg(not(target_family = "unix"))]
fs::DirBuilder::new().recursive(true).create(parent)?;
}
if let Some(parent) = config.vault_path.parent() {
#[cfg(target_family = "unix")]
fs::DirBuilder::new()
.mode(0o700)
.recursive(true)
.create(parent)?;
#[cfg(not(target_family = "unix"))]
fs::DirBuilder::new().recursive(true).create(parent)?;
}
let backend = unlock_backend(&config);
let vault_exists = config.vault_path.exists();
let master_passphrase = if passphrase_stdin || passphrase_pipe {
read_passphrase_stdin()?
} else if vault_exists {
backend.retrieve_passphrase()?
} else if config.backend == "fido2" || config.backend == "passphrase" {
backend.set_passphrase()?
} else {
String::new()
};
let identities = new_identity_map();
let vault: Arc<Mutex<Option<Vault>>> = Arc::new(Mutex::new(None));
let master_passphrase_arc = Arc::new(Mutex::new(master_passphrase.clone()));
let locked: Arc<Mutex<bool>> = Arc::new(Mutex::new(false));
if config.vault_path.exists() {
match reload_from_vault(&identities, &vault, &config.vault_path, &master_passphrase).await {
Ok(n) => info!("loaded {n} identities from vault"),
Err(e) => error!("failed to load vault: {e}"),
}
}
if !config.foreground {
check_not_already_running(&config.lock_path, &config.socket_path).await?;
}
if config.socket_path.exists() {
fs::remove_file(&config.socket_path)?;
}
let listener = UnixListener::bind(&config.socket_path)?;
print_socket_env(&config.socket_path, config.shell);
if !config.foreground {
return spawn_daemon_child(
socket_override,
vault_override,
&config.backend,
config.shell,
&master_passphrase,
);
}
info!(
"moshpit-agent listening on {}",
config.socket_path.display()
);
let pid = std::process::id();
if let Err(e) = fs::write(&config.lock_path, format!("{pid}\n")) {
warn!(
"failed to write lock file {}: {e}",
config.lock_path.display()
);
}
let socket_path_cleanup = config.socket_path.clone();
let lock_path_cleanup = config.lock_path.clone();
#[cfg(target_family = "unix")]
{
use tokio::signal::unix::{SignalKind, signal};
let mut sigterm = signal(SignalKind::terminate())?;
let mut sigint = signal(SignalKind::interrupt())?;
let mut sighup = signal(SignalKind::hangup())?;
let cleanup_path = socket_path_cleanup.clone();
let cleanup_lock = lock_path_cleanup.clone();
drop(tokio::spawn(async move {
loop {
tokio::select! {
_ = sigterm.recv() => break,
_ = sigint.recv() => break,
_ = sighup.recv() => {} }
}
drop(fs::remove_file(&cleanup_path));
drop(fs::remove_file(&cleanup_lock));
std::process::exit(0);
}));
}
loop {
match listener.accept().await {
Ok((stream, _)) => {
let ids = Arc::clone(&identities);
let v = Arc::clone(&vault);
let vp = config.vault_path.clone();
let mp = Arc::clone(&master_passphrase_arc);
let sp = config.socket_path.clone();
let lp = config.lock_path.clone();
let lk = Arc::clone(&locked);
drop(tokio::spawn(async move {
handle_connection(stream, ids, v, vp, mp, sp, lp, lk).await;
}));
}
Err(e) => error!("accept error: {e}"),
}
}
}
#[cfg(test)]
mod tests {
use tempfile::tempdir;
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use super::*;
const TEST_KEY_PATH: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../libmoshpit/tests/keys/id_x25519_test"
);
fn dummy_identity(fp: &str, algorithm: &str) -> Identity {
Identity {
full_pub_key_bytes: b"dummy pub key bytes".to_vec(),
algorithm: algorithm.to_string(),
fingerprint: fp.to_string(),
private_key: vec![0u8; 32],
}
}
fn empty_vault() -> Arc<Mutex<Option<Vault>>> {
Arc::new(Mutex::new(None))
}
fn empty_passphrase() -> Arc<Mutex<String>> {
Arc::new(Mutex::new(String::new()))
}
fn unlocked_state() -> Arc<Mutex<bool>> {
Arc::new(Mutex::new(false))
}
#[test]
fn new_identity_map_is_empty() {
let map = new_identity_map();
assert_eq!(Arc::strong_count(&map), 1);
}
#[tokio::test]
async fn dispatch_list_identities_empty() {
let ids = new_identity_map();
let vault = empty_vault();
let vault_path = PathBuf::from("/tmp/nonexistent-vault-agent-test");
let mp = empty_passphrase();
let resp = dispatch_request(
AgentRequest::ListIdentities,
&ids,
&vault,
&vault_path,
&mp,
&unlocked_state(),
)
.await;
assert!(matches!(resp, AgentResponse::Identities(v) if v.is_empty()));
}
#[tokio::test]
async fn dispatch_list_identities_populated() {
let ids = new_identity_map();
let fp = "SHA256:aaaabbbb".to_string();
drop(
ids.lock()
.await
.insert(fp.clone(), dummy_identity(&fp, "X25519")),
);
let vault = empty_vault();
let vault_path = PathBuf::from("/tmp/nonexistent-vault-agent-test");
let mp = empty_passphrase();
let resp = dispatch_request(
AgentRequest::ListIdentities,
&ids,
&vault,
&vault_path,
&mp,
&unlocked_state(),
)
.await;
match resp {
AgentResponse::Identities(list) => {
assert_eq!(list.len(), 1);
assert_eq!(list[0].fingerprint, fp);
assert_eq!(list[0].algorithm, "X25519");
}
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test]
async fn dispatch_list_supported_identities_filtered() {
let ids = new_identity_map();
let fp1 = "SHA256:fp1".to_string();
let fp2 = "SHA256:fp2".to_string();
drop(
ids.lock()
.await
.insert(fp1.clone(), dummy_identity(&fp1, "X25519")),
);
drop(
ids.lock()
.await
.insert(fp2.clone(), dummy_identity(&fp2, "P384")),
);
let vault = empty_vault();
let vault_path = PathBuf::from("/tmp/nonexistent-vault-agent-test");
let mp = empty_passphrase();
let resp = dispatch_request(
AgentRequest::ListSupportedIdentities {
supported_algorithms: vec!["P384".to_string()],
},
&ids,
&vault,
&vault_path,
&mp,
&unlocked_state(),
)
.await;
match resp {
AgentResponse::Identities(list) => {
assert_eq!(list.len(), 1);
assert_eq!(list[0].algorithm, "P384");
}
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test]
async fn dispatch_get_public_key_found() {
let ids = new_identity_map();
let fp = "SHA256:found".to_string();
drop(
ids.lock()
.await
.insert(fp.clone(), dummy_identity(&fp, "X25519")),
);
let vault = empty_vault();
let vault_path = PathBuf::from("/tmp/nonexistent-vault-agent-test");
let mp = empty_passphrase();
let resp = dispatch_request(
AgentRequest::GetPublicKey(fp.clone()),
&ids,
&vault,
&vault_path,
&mp,
&unlocked_state(),
)
.await;
assert!(matches!(resp, AgentResponse::PublicKey(b) if b == b"dummy pub key bytes"));
}
#[tokio::test]
async fn dispatch_get_public_key_not_found() {
let ids = new_identity_map();
let vault = empty_vault();
let vault_path = PathBuf::from("/tmp/nonexistent-vault-agent-test");
let mp = empty_passphrase();
let resp = dispatch_request(
AgentRequest::GetPublicKey("SHA256:missing".to_string()),
&ids,
&vault,
&vault_path,
&mp,
&unlocked_state(),
)
.await;
assert!(matches!(resp, AgentResponse::Error(_)));
}
#[tokio::test]
async fn dispatch_sign_not_found() {
let ids = new_identity_map();
let vault = empty_vault();
let vault_path = PathBuf::from("/tmp/nonexistent-vault-agent-test");
let mp = empty_passphrase();
let resp = dispatch_request(
AgentRequest::Sign {
fingerprint: "SHA256:nosuchkey".to_string(),
data: b"hello".to_vec(),
},
&ids,
&vault,
&vault_path,
&mp,
&unlocked_state(),
)
.await;
assert!(matches!(resp, AgentResponse::Error(_)));
}
#[tokio::test]
async fn dispatch_sign_key_exists_non_unstable() {
let ids = new_identity_map();
let fp = "SHA256:signing".to_string();
drop(
ids.lock()
.await
.insert(fp.clone(), dummy_identity(&fp, "X25519")),
);
let vault = empty_vault();
let vault_path = PathBuf::from("/tmp/nonexistent-vault-agent-test");
let mp = empty_passphrase();
let resp = dispatch_request(
AgentRequest::Sign {
fingerprint: fp.clone(),
data: b"some data".to_vec(),
},
&ids,
&vault,
&vault_path,
&mp,
&unlocked_state(),
)
.await;
assert!(matches!(resp, AgentResponse::Error(_)));
}
#[test]
fn sign_data_non_unstable_returns_error() {
let id = dummy_identity("SHA256:test", "X25519");
let resp = sign_data(&id, b"data");
assert!(
matches!(resp, AgentResponse::Error(ref msg) if msg.contains("does not support signing"))
);
}
#[tokio::test]
async fn dispatch_add_identity() {
let ids = new_identity_map();
let vault = empty_vault();
let dir = tempdir().unwrap();
let vault_path = dir.path().join("vault");
let mp = empty_passphrase();
let resp = dispatch_request(
AgentRequest::AddIdentity {
key_path: TEST_KEY_PATH.to_string(),
passphrase: None,
},
&ids,
&vault,
&vault_path,
&mp,
&unlocked_state(),
)
.await;
assert!(matches!(resp, AgentResponse::Ok));
assert_eq!(ids.lock().await.len(), 1);
}
#[tokio::test]
async fn dispatch_add_identity_bad_path() {
let ids = new_identity_map();
let vault = empty_vault();
let vault_path = PathBuf::from("/tmp/nonexistent-vault-agent-test");
let mp = empty_passphrase();
let resp = dispatch_request(
AgentRequest::AddIdentity {
key_path: "/nonexistent/key/path".to_string(),
passphrase: None,
},
&ids,
&vault,
&vault_path,
&mp,
&unlocked_state(),
)
.await;
assert!(matches!(resp, AgentResponse::Error(_)));
}
#[tokio::test]
async fn dispatch_remove_identity_found() {
let ids = new_identity_map();
let fp = "SHA256:removeme".to_string();
drop(
ids.lock()
.await
.insert(fp.clone(), dummy_identity(&fp, "X25519")),
);
let vault = empty_vault();
let vault_path = PathBuf::from("/tmp/nonexistent-vault-agent-test");
let mp = empty_passphrase();
let resp = dispatch_request(
AgentRequest::RemoveIdentity(fp.clone()),
&ids,
&vault,
&vault_path,
&mp,
&unlocked_state(),
)
.await;
assert!(matches!(resp, AgentResponse::Ok));
assert!(ids.lock().await.is_empty());
}
#[tokio::test]
async fn dispatch_remove_identity_not_found() {
let ids = new_identity_map();
let vault = empty_vault();
let vault_path = PathBuf::from("/tmp/nonexistent-vault-agent-test");
let mp = empty_passphrase();
let resp = dispatch_request(
AgentRequest::RemoveIdentity("SHA256:nosuch".to_string()),
&ids,
&vault,
&vault_path,
&mp,
&unlocked_state(),
)
.await;
assert!(matches!(resp, AgentResponse::Error(_)));
}
#[tokio::test]
async fn dispatch_remove_all_identities() {
let ids = new_identity_map();
let fp1 = "SHA256:one".to_string();
let fp2 = "SHA256:two".to_string();
drop(
ids.lock()
.await
.insert(fp1.clone(), dummy_identity(&fp1, "X25519")),
);
drop(
ids.lock()
.await
.insert(fp2.clone(), dummy_identity(&fp2, "P384")),
);
let vault = empty_vault();
let vault_path = PathBuf::from("/tmp/nonexistent-vault-agent-test");
let mp = empty_passphrase();
let resp = dispatch_request(
AgentRequest::RemoveAllIdentities,
&ids,
&vault,
&vault_path,
&mp,
&unlocked_state(),
)
.await;
assert!(matches!(resp, AgentResponse::Ok));
assert!(ids.lock().await.is_empty());
}
#[tokio::test]
async fn dispatch_lock() {
let ids = new_identity_map();
let fp = "SHA256:lockme".to_string();
drop(
ids.lock()
.await
.insert(fp.clone(), dummy_identity(&fp, "X25519")),
);
let vault = empty_vault();
let vault_path = PathBuf::from("/tmp/nonexistent-vault-agent-test");
let mp = empty_passphrase();
let resp = dispatch_request(
AgentRequest::Lock,
&ids,
&vault,
&vault_path,
&mp,
&unlocked_state(),
)
.await;
assert!(matches!(resp, AgentResponse::Ok));
assert!(ids.lock().await.is_empty());
}
#[tokio::test]
async fn dispatch_unlock_no_vault() {
let ids = new_identity_map();
let vault = empty_vault();
let vault_path = PathBuf::from("/tmp/nonexistent-vault-for-unlock-agent-test");
let mp = empty_passphrase();
let resp = dispatch_request(
AgentRequest::Unlock(String::new()),
&ids,
&vault,
&vault_path,
&mp,
&unlocked_state(),
)
.await;
assert!(matches!(resp, AgentResponse::Ok));
assert!(ids.lock().await.is_empty());
}
#[tokio::test]
async fn dispatch_status_unlocked_empty() {
let ids = new_identity_map();
let vault = empty_vault();
let vault_path = PathBuf::from("/tmp/nonexistent-vault-agent-test");
let mp = empty_passphrase();
let resp = dispatch_request(
AgentRequest::Status,
&ids,
&vault,
&vault_path,
&mp,
&unlocked_state(),
)
.await;
match resp {
AgentResponse::AgentStatus { locked, identities } => {
assert!(!locked);
assert!(identities.is_empty());
}
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test]
async fn dispatch_status_unlocked_with_identity() {
let ids = new_identity_map();
let fp = "SHA256:status-key".to_string();
drop(
ids.lock()
.await
.insert(fp.clone(), dummy_identity(&fp, "X25519")),
);
let vault = empty_vault();
let vault_path = PathBuf::from("/tmp/nonexistent-vault-agent-test");
let mp = empty_passphrase();
let resp = dispatch_request(
AgentRequest::Status,
&ids,
&vault,
&vault_path,
&mp,
&unlocked_state(),
)
.await;
match resp {
AgentResponse::AgentStatus { locked, identities } => {
assert!(!locked);
assert_eq!(identities.len(), 1);
assert_eq!(identities[0].fingerprint, fp);
assert_eq!(identities[0].algorithm, "X25519");
}
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test]
async fn dispatch_status_locked_state() {
let ids = new_identity_map();
let vault = empty_vault();
let vault_path = PathBuf::from("/tmp/nonexistent-vault-agent-test");
let mp = empty_passphrase();
let locked = Arc::new(Mutex::new(true));
let resp = dispatch_request(
AgentRequest::Status,
&ids,
&vault,
&vault_path,
&mp,
&locked,
)
.await;
match resp {
AgentResponse::AgentStatus { locked, identities } => {
assert!(locked, "agent should report locked state");
assert!(identities.is_empty());
}
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test]
async fn dispatch_lock_sets_locked_flag() {
let ids = new_identity_map();
let fp = "SHA256:flagme".to_string();
drop(
ids.lock()
.await
.insert(fp.clone(), dummy_identity(&fp, "P384")),
);
let vault = empty_vault();
let vault_path = PathBuf::from("/tmp/nonexistent-vault-agent-test");
let mp = empty_passphrase();
let locked = unlocked_state();
let resp =
dispatch_request(AgentRequest::Lock, &ids, &vault, &vault_path, &mp, &locked).await;
assert!(matches!(resp, AgentResponse::Ok));
assert!(*locked.lock().await, "locked flag must be true after Lock");
}
#[tokio::test]
async fn handle_connection_single_request() {
let ids = new_identity_map();
let vault = empty_vault();
let vault_path = PathBuf::from("/tmp/nonexistent-vault-agent-test");
let mp = empty_passphrase();
let (mut client, server) = UnixStream::pair().unwrap();
drop(tokio::spawn(handle_connection(
server,
Arc::clone(&ids),
Arc::clone(&vault),
vault_path,
Arc::clone(&mp),
PathBuf::from("/tmp/nonexistent-socket-agent-test"),
PathBuf::from("/tmp/nonexistent-lock-agent-test"),
Arc::new(Mutex::new(false)),
)));
let req = AgentRequest::ListIdentities;
let encoded = encode_to_vec(&req, standard()).unwrap();
let len = u32::try_from(encoded.len()).unwrap();
client.write_all(&len.to_be_bytes()).await.unwrap();
client.write_all(&encoded).await.unwrap();
client.flush().await.unwrap();
let resp_len = client.read_u32().await.unwrap() as usize;
let mut buf = vec![0u8; resp_len];
let _ = client.read_exact(&mut buf).await.unwrap();
let (resp, _): (AgentResponse, _) = decode_from_slice(&buf, standard()).unwrap();
assert!(matches!(resp, AgentResponse::Identities(v) if v.is_empty()));
drop(client);
}
#[tokio::test]
async fn handle_connection_zero_length_breaks() {
let ids = new_identity_map();
let vault = empty_vault();
let vault_path = PathBuf::from("/tmp/nonexistent-vault-agent-test");
let mp = empty_passphrase();
let (mut client, server) = UnixStream::pair().unwrap();
let task = tokio::spawn(handle_connection(
server,
ids,
vault,
vault_path,
mp,
PathBuf::from("/tmp/nonexistent-socket-agent-test"),
PathBuf::from("/tmp/nonexistent-lock-agent-test"),
Arc::new(Mutex::new(false)),
));
client.write_all(&0u32.to_be_bytes()).await.unwrap();
client.flush().await.unwrap();
drop(client);
task.await.unwrap();
}
#[tokio::test]
async fn handle_connection_bad_decode_breaks() {
let ids = new_identity_map();
let vault = empty_vault();
let vault_path = PathBuf::from("/tmp/nonexistent-vault-agent-test");
let mp = empty_passphrase();
let (mut client, server) = UnixStream::pair().unwrap();
let task = tokio::spawn(handle_connection(
server,
ids,
vault,
vault_path,
mp,
PathBuf::from("/tmp/nonexistent-socket-agent-test"),
PathBuf::from("/tmp/nonexistent-lock-agent-test"),
Arc::new(Mutex::new(false)),
));
let garbage = vec![0xFF_u8, 0xFE, 0xFD, 0xFC];
let len = u32::try_from(garbage.len()).unwrap();
client.write_all(&len.to_be_bytes()).await.unwrap();
client.write_all(&garbage).await.unwrap();
client.flush().await.unwrap();
drop(client);
task.await.unwrap();
}
#[test]
fn best_identity_prefers_p384_over_x25519() {
let ids = vec![
AgentIdentityInfo {
algorithm: "X25519".into(),
fingerprint: "SHA256:x25519".into(),
comment: String::new(),
},
AgentIdentityInfo {
algorithm: "P384".into(),
fingerprint: "SHA256:p384".into(),
comment: String::new(),
},
];
let best = best_identity(&ids);
assert_eq!(best.algorithm, "P384");
}
#[test]
fn best_identity_single_unknown_returns_first() {
let ids = vec![AgentIdentityInfo {
algorithm: "Unknown".into(),
fingerprint: "SHA256:unk".into(),
comment: String::new(),
}];
let best = best_identity(&ids);
assert_eq!(best.algorithm, "Unknown");
}
#[test]
#[allow(unsafe_code)]
fn socket_from_env_set() {
unsafe { std::env::set_var("MOSHPIT_AGENT_SOCK", "/tmp/mpa-test-socket.sock") };
let result = socket_from_env();
unsafe { std::env::remove_var("MOSHPIT_AGENT_SOCK") };
assert_eq!(result.unwrap(), PathBuf::from("/tmp/mpa-test-socket.sock"));
}
#[test]
#[allow(unsafe_code)]
fn socket_from_env_not_set() {
unsafe { std::env::remove_var("MOSHPIT_AGENT_SOCK") };
assert!(socket_from_env().is_err());
}
#[test]
fn unlock_backend_passphrase_default() {
let config = AgentConfig::resolve(
Some("/tmp/dummy.sock"),
Some("/tmp/dummy.vault"),
false,
"unknown-backend".to_string(),
ShellKind::Fish,
);
let backend = unlock_backend(&config);
assert_eq!(backend.name(), "passphrase");
}
#[test]
fn format_socket_env_fish() {
let path = Path::new("/run/user/1000/moshpit-agent.sock");
let s = format_socket_env(path, ShellKind::Fish);
assert_eq!(
s,
"set -Ux MOSHPIT_AGENT_SOCK /run/user/1000/moshpit-agent.sock"
);
}
#[test]
fn format_socket_env_bash() {
let path = Path::new("/run/user/1000/moshpit-agent.sock");
let s = format_socket_env(path, ShellKind::Bash);
assert_eq!(
s,
"MOSHPIT_AGENT_SOCK=/run/user/1000/moshpit-agent.sock; export MOSHPIT_AGENT_SOCK"
);
}
#[test]
fn format_unset_socket_env_fish() {
assert_eq!(
format_unset_socket_env(ShellKind::Fish),
"set -e MOSHPIT_AGENT_SOCK"
);
}
#[test]
fn format_unset_socket_env_bash() {
assert_eq!(
format_unset_socket_env(ShellKind::Bash),
"unset MOSHPIT_AGENT_SOCK"
);
}
#[cfg(target_family = "unix")]
#[test]
fn is_process_alive_self() {
assert!(is_process_alive(std::process::id()));
}
#[cfg(target_family = "unix")]
#[test]
fn is_process_alive_dead_pid() {
let mut child = std::process::Command::new("true").spawn().unwrap();
let pid = child.id();
let _ = child.wait().unwrap();
assert!(!is_process_alive(pid));
}
#[tokio::test]
async fn check_no_files_ok() {
let dir = tempdir().unwrap();
let lock = dir.path().join("test.lock");
let sock = dir.path().join("test.sock");
assert!(check_not_already_running(&lock, &sock).await.is_ok());
}
#[tokio::test]
async fn check_stale_lock_no_socket_cleans_up() {
let dir = tempdir().unwrap();
let lock = dir.path().join("test.lock");
let sock = dir.path().join("test.sock");
let mut child = std::process::Command::new("true").spawn().unwrap();
let pid = child.id();
let _ = child.wait().unwrap();
fs::write(&lock, format!("{pid}\n")).unwrap();
assert!(check_not_already_running(&lock, &sock).await.is_ok());
assert!(!lock.exists(), "stale lock file must be removed");
}
#[tokio::test]
async fn check_live_lock_and_socket_errors() {
let dir = tempdir().unwrap();
let lock = dir.path().join("test.lock");
let sock = dir.path().join("test.sock");
let listener = UnixListener::bind(&sock).unwrap();
drop(tokio::spawn(async move { drop(listener.accept().await) }));
fs::write(&lock, format!("{}\n", std::process::id())).unwrap();
let err = check_not_already_running(&lock, &sock).await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("already running"), "msg: {msg}");
assert!(msg.contains("mpa stop"), "msg: {msg}");
}
#[tokio::test]
async fn check_socket_live_no_lock_errors() {
let dir = tempdir().unwrap();
let lock = dir.path().join("test.lock");
let sock = dir.path().join("test.sock");
let listener = UnixListener::bind(&sock).unwrap();
drop(tokio::spawn(async move { drop(listener.accept().await) }));
assert!(check_not_already_running(&lock, &sock).await.is_err());
}
}