use std::fs::File;
use std::io::BufReader;
use std::path::PathBuf;
use clap::{ArgAction, Parser, Subcommand};
use clap_serde_derive::ClapSerde;
use libc::{PR_SET_DUMPABLE, prctl};
use libc::{mlock, munlock};
use log::debug;
use nix::sys::resource::{Resource, setrlimit};
use passless_config_doc::ConfigDoc;
use serde::{Deserialize, Serialize};
pub fn local_path() -> String {
dirs::data_dir()
.expect("Could not determine data directory: $XDG_DATA_HOME or $HOME/.local/share")
.join("passless/local")
.to_string_lossy()
.into_owned()
}
#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
#[group(id = "local-backend-config")]
pub struct LocalBackendConfig {
#[arg(
long = "local-path",
env = "PASSLESS_LOCAL_PATH",
id = "local-path",
value_name = "PATH"
)]
#[serde(default)]
#[default(local_path())]
pub path: String,
}
pub fn pass_store_path() -> String {
dirs::home_dir()
.expect("Could not determine home directory: $HOME")
.join(".password-store")
.to_string_lossy()
.into_owned()
}
#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
#[group(id = "pass-backend-config")]
pub struct PassBackendConfig {
#[arg(
long = "pass-store-path",
env = "PASSLESS_PASS_STORE_PATH",
id = "pass-store-path",
value_name = "PATH"
)]
#[serde(default)]
#[default(pass_store_path())]
pub store_path: String,
#[arg(
long = "pass-path",
env = "PASSLESS_PASS_PATH",
id = "pass-path",
value_name = "PATH"
)]
#[serde(default)]
#[default("fido2".to_string())]
pub path: String,
#[arg(
long = "pass-gpg-backend",
env = "PASSLESS_PASS_GPG_BACKEND",
value_name = "BACKEND"
)]
#[serde(default)]
#[default("gnupg-bin".to_string())]
pub gpg_backend: String,
}
pub fn tpm_path() -> String {
dirs::data_dir()
.expect("Could not determine data directory: $XDG_DATA_HOME or $HOME/.local/share")
.join("passless/tpm")
.to_string_lossy()
.into_owned()
}
#[cfg(feature = "tpm")]
#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
#[group(id = "tpm-backend-config")]
pub struct TpmBackendConfig {
#[arg(
long = "tpm-path",
env = "PASSLESS_TPM_PATH",
id = "tpm-path",
value_name = "PATH"
)]
#[serde(default)]
#[default(tpm_path())]
pub path: String,
#[arg(long = "tpm-tcti", env = "PASSLESS_TPM_TCTI", value_name = "TCTI")]
#[serde(default)]
#[default("device:/dev/tpmrm0".to_string())]
pub tcti: String,
}
#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
#[group(id = "security")]
pub struct SecurityConfig {
#[arg(long = "check-mlock", env = "PASSLESS_CHECK_MLOCK")]
#[serde(default)]
#[default(true)]
pub check_mlock: bool,
#[arg(long = "disable-core-dumps", env = "PASSLESS_DISABLE_CORE_DUMPS")]
#[serde(default)]
#[default(true)]
pub disable_core_dumps: bool,
#[arg(
long = "constant-signature-counter",
env = "PASSLESS_CONSTANT_SIGNATURE_COUNTER",
action = ArgAction::Set,
require_equals = true,
num_args = 0..=1,
default_missing_value = "true"
)]
#[serde(default)]
pub constant_signature_counter: bool,
#[arg(
long = "always-uv",
env = "PASSLESS_ALWAYS_UV",
action = ArgAction::Set,
require_equals = true,
num_args = 0..=1,
default_value = "true",
default_missing_value = "true"
)]
#[serde(default)]
#[default(true)]
pub always_uv: bool,
#[arg(
long = "user-verification-registration",
env = "PASSLESS_USER_VERIFICATION_REGISTRATION"
)]
#[serde(default)]
#[default(true)]
pub user_verification_registration: bool,
#[arg(
long = "user-verification-authentication",
env = "PASSLESS_USER_VERIFICATION_AUTHENTICATION"
)]
#[serde(default)]
#[default(true)]
pub user_verification_authentication: bool,
#[arg(
long = "notification-timeout",
env = "PASSLESS_NOTIFICATION_TIMEOUT",
value_name = "SECONDS"
)]
#[serde(default)]
#[default(30)]
pub notification_timeout: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum PinEnforcement {
Never,
#[default]
Optional,
Required,
}
impl std::str::FromStr for PinEnforcement {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"never" => Ok(PinEnforcement::Never),
"optional" => Ok(PinEnforcement::Optional),
"required" => Ok(PinEnforcement::Required),
_ => Err(format!(
"Invalid PIN enforcement '{}'. Must be: never, optional, or required",
s
)),
}
}
}
impl std::fmt::Display for PinEnforcement {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PinEnforcement::Never => write!(f, "never"),
PinEnforcement::Optional => write!(f, "optional"),
PinEnforcement::Required => write!(f, "required"),
}
}
}
#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
#[group(id = "pin")]
pub struct PinConfig {
#[arg(
long = "pin-enforcement",
env = "PASSLESS_PIN_ENFORCEMENT",
value_name = "POLICY"
)]
#[serde(default)]
#[default(PinEnforcement::Optional)]
pub enforcement: PinEnforcement,
#[arg(
long = "pin-min-length",
env = "PASSLESS_PIN_MIN_LENGTH",
value_name = "LENGTH"
)]
#[serde(default)]
#[default(4)]
pub min_length: u8,
#[arg(
long = "pin-max-retries",
env = "PASSLESS_PIN_MAX_RETRIES",
value_name = "RETRIES"
)]
#[serde(default)]
#[default(8)]
pub max_retries: u8,
#[arg(
long = "pin-auto-lock-timeout",
env = "PASSLESS_PIN_AUTO_LOCK_TIMEOUT",
value_name = "SECONDS"
)]
#[serde(default)]
#[default(0)]
pub auto_lock_timeout: u32,
}
impl SecurityConfig {
pub fn apply_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
if self.disable_core_dumps {
self.disable_core_dumps_impl()?;
}
if self.check_mlock {
self.probe_mlock_capability()?;
}
Ok(())
}
fn disable_core_dumps_impl(&self) -> Result<(), Box<dyn std::error::Error>> {
debug!("Disabling core dumps to prevent credential leakage");
setrlimit(Resource::RLIMIT_CORE, 0, 0)?;
let r = unsafe { prctl(PR_SET_DUMPABLE, 0, 0, 0, 0) };
if r != 0 {
log::warn!("prctl(PR_SET_DUMPABLE) failed: {}", r);
}
Ok(())
}
fn probe_mlock_capability(&self) -> Result<(), Box<dyn std::error::Error>> {
debug!("Check mlock capability");
let test_size = 4096;
let test_buffer = vec![0u8; test_size];
let ptr = test_buffer.as_ptr() as *const libc::c_void;
let lock_result = unsafe { mlock(ptr, test_size) };
if lock_result == 0 {
unsafe { munlock(ptr, test_size) };
log::debug!("MLOCK is enabled - sensitive data will not be swapped to disk");
} else {
log::warn!(
"mlock capability probe failed - memory locking may not be available.\n\
Hint: grant CAP_IPC_LOCK to the binary with: 'sudo setcap cap_ipc_lock=+ep $(which passless)'"
);
}
Ok(())
}
}
#[derive(ClapSerde, Serialize, Deserialize, Debug, ConfigDoc)]
pub struct AppConfig {
#[arg(short = 't', long = "backend-type", env = "PASSLESS_BACKEND_TYPE")]
#[serde(default)]
#[default("pass".to_string())]
pub backend_type: String,
#[arg(
short,
long,
env = "PASSLESS_VERBOSE",
action = ArgAction::Set,
require_equals = true,
num_args = 0..=1,
default_missing_value = "true"
)]
#[default(true)]
#[serde(default)]
pub verbose: bool,
#[clap_serde]
#[serde(default)]
#[command(flatten)]
pub pass: PassBackendConfig,
#[cfg(feature = "tpm")]
#[clap_serde]
#[serde(default)]
#[command(flatten)]
pub tpm: TpmBackendConfig,
#[clap_serde]
#[serde(default)]
#[command(flatten)]
pub local: LocalBackendConfig,
#[clap_serde]
#[serde(default)]
#[command(flatten)]
pub security: SecurityConfig,
#[clap_serde]
#[serde(default)]
#[command(flatten)]
pub pin: PinConfig,
}
#[derive(Debug, Clone)]
pub enum BackendConfig {
Local {
path: String,
},
Pass {
store_path: String,
path: String,
gpg_backend: String,
},
#[cfg(feature = "tpm")]
Tpm {
path: String,
tcti: String,
},
}
impl AppConfig {
pub fn load(args: &mut Args) -> Self {
let default_config_path = dirs::config_dir().map(|p| p.join("passless/config.toml"));
let config_file_path = args
.config_path
.as_ref()
.or(default_config_path.as_ref())
.filter(|p| p.exists());
if let Some(path) = config_file_path
&& let Ok(f) = File::open(path)
{
log::info!("Loading configuration from: {}", path.display());
let content = std::io::read_to_string(BufReader::new(f)).unwrap_or_default();
match toml::from_str::<<AppConfig as ClapSerde>::Opt>(&content) {
Ok(file_config) => {
return AppConfig::from(file_config).merge(&mut args.config);
}
Err(e) => log::warn!("Failed to parse config file {}: {}", path.display(), e),
}
}
AppConfig::from(&mut args.config)
}
pub fn backend(&self) -> crate::error::Result<BackendConfig> {
match self.backend_type.as_str() {
"local" => Ok(BackendConfig::Local {
path: self.local.path.clone(),
}),
"pass" => Ok(BackendConfig::Pass {
store_path: self.pass.store_path.clone(),
path: self.pass.path.clone(),
gpg_backend: self.pass.gpg_backend.clone(),
}),
#[cfg(feature = "tpm")]
"tpm" => Ok(BackendConfig::Tpm {
path: self.tpm.path.clone(),
tcti: self.tpm.tcti.clone(),
}),
_ => Err(crate::error::Error::Config(format!(
"Invalid backend_type '{}'. Must be one of: local, pass, tpm",
self.backend_type
))),
}
}
pub fn apply_security_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
self.security.apply_hardening()
}
pub fn security_config(&self) -> SecurityConfig {
self.security.clone()
}
pub fn pin_config(&self) -> PinConfig {
self.pin.clone()
}
}
#[derive(Parser)]
#[command(author, version, about)]
pub struct Args {
#[arg(short, long, env = "PASSLESS_CONFIG")]
pub config_path: Option<PathBuf>,
#[command(flatten)]
pub config: <AppConfig as ClapSerde>::Opt,
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Plain,
Json,
}
impl std::str::FromStr for OutputFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"plain" => Ok(OutputFormat::Plain),
"json" => Ok(OutputFormat::Json),
_ => Err(format!(
"Invalid output format '{}'. Must be 'plain' or 'json'",
s
)),
}
}
}
impl std::fmt::Display for OutputFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
OutputFormat::Plain => write!(f, "plain"),
OutputFormat::Json => write!(f, "json"),
}
}
}
#[derive(Subcommand, Debug, Clone)]
pub enum Commands {
Config {
#[command(subcommand)]
action: ConfigAction,
},
Client {
#[arg(short = 'D', long = "device", value_name = "INDEX|NAME", global = true)]
device: Option<String>,
#[arg(
short = 'o',
long = "output",
value_name = "FORMAT",
default_value = "plain",
global = true
)]
output: OutputFormat,
#[command(subcommand)]
action: ClientAction,
},
}
#[derive(Subcommand, Debug, Clone)]
pub enum ConfigAction {
Print,
}
#[derive(Subcommand, Debug, Clone)]
pub enum ClientAction {
Devices,
Info,
Reset {
#[arg(long = "yes-i-really-want-to-reset-my-device", action = ArgAction::Count)]
confirm: u8,
},
List {
#[arg(short = 'd', long = "domain", value_name = "DOMAIN")]
rp_id: Option<String>,
},
Show {
#[arg(value_name = "CREDENTIAL_ID")]
credential_id: String,
},
Delete {
#[arg(value_name = "CREDENTIAL_ID")]
credential_id: String,
},
Rename {
#[arg(value_name = "CREDENTIAL_ID")]
credential_id: String,
#[arg(short = 'u', long = "user-name", value_name = "NAME")]
user_name: Option<String>,
#[arg(short = 'n', long = "display-name", value_name = "NAME")]
display_name: Option<String>,
},
Pin {
#[command(subcommand)]
action: PinAction,
},
}
#[derive(Subcommand, Debug, Clone)]
pub enum PinAction {
Set {
#[arg(value_name = "PIN")]
pin: String,
},
Change {
#[arg(value_name = "OLD_PIN")]
old_pin: String,
#[arg(value_name = "NEW_PIN")]
new_pin: String,
},
UvReset,
}