1use std::fs::File;
9use std::io::BufReader;
10use std::path::PathBuf;
11
12use clap::{ArgAction, Parser, Subcommand};
13use clap_serde_derive::ClapSerde;
14use libc::{PR_SET_DUMPABLE, prctl};
15use libc::{mlock, munlock};
16use log::debug;
17use nix::sys::resource::{Resource, setrlimit};
18use passless_config_doc::ConfigDoc;
19use serde::{Deserialize, Serialize};
20
21pub fn local_path() -> String {
23 dirs::data_dir()
24 .expect("Could not determine data directory: $XDG_DATA_HOME or $HOME/.local/share")
25 .join("passless/local")
26 .to_string_lossy()
27 .into_owned()
28}
29
30#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
32#[group(id = "local-backend-config")]
33pub struct LocalBackendConfig {
34 #[arg(
36 long = "local-path",
37 env = "PASSLESS_LOCAL_PATH",
38 id = "local-path",
39 value_name = "PATH"
40 )]
41 #[serde(default)]
42 #[default(local_path())]
43 pub path: String,
44}
45
46pub fn pass_store_path() -> String {
48 dirs::home_dir()
49 .expect("Could not determine home directory: $HOME")
50 .join(".password-store")
51 .to_string_lossy()
52 .into_owned()
53}
54#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
56#[group(id = "pass-backend-config")]
57pub struct PassBackendConfig {
58 #[arg(
60 long = "pass-store-path",
61 env = "PASSLESS_PASS_STORE_PATH",
62 id = "pass-store-path",
63 value_name = "PATH"
64 )]
65 #[serde(default)]
66 #[default(pass_store_path())]
67 pub store_path: String,
68
69 #[arg(
71 long = "pass-path",
72 env = "PASSLESS_PASS_PATH",
73 id = "pass-path",
74 value_name = "PATH"
75 )]
76 #[serde(default)]
77 #[default("fido2".to_string())]
78 pub path: String,
79
80 #[arg(
82 long = "pass-gpg-backend",
83 env = "PASSLESS_PASS_GPG_BACKEND",
84 value_name = "BACKEND"
85 )]
86 #[serde(default)]
87 #[default("gnupg-bin".to_string())]
88 pub gpg_backend: String,
89}
90
91pub fn tpm_path() -> String {
93 dirs::data_dir()
94 .expect("Could not determine data directory: $XDG_DATA_HOME or $HOME/.local/share")
95 .join("passless/tpm")
96 .to_string_lossy()
97 .into_owned()
98}
99
100#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
102#[group(id = "tpm-backend-config")]
103pub struct TpmBackendConfig {
104 #[arg(
106 long = "tpm-path",
107 env = "PASSLESS_TPM_PATH",
108 id = "tpm-path",
109 value_name = "PATH"
110 )]
111 #[serde(default)]
112 #[default(tpm_path())]
113 pub path: String,
114
115 #[arg(long = "tpm-tcti", env = "PASSLESS_TPM_TCTI", value_name = "TCTI")]
117 #[serde(default)]
118 #[default("device:/dev/tpmrm0".to_string())]
119 pub tcti: String,
120}
121
122#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
124#[group(id = "security")]
125pub struct SecurityConfig {
126 #[arg(long = "check-mlock", env = "PASSLESS_CHECK_MLOCK")]
128 #[serde(default)]
129 #[default(true)]
130 pub check_mlock: bool,
131
132 #[arg(long = "disable-core-dumps", env = "PASSLESS_DISABLE_CORE_DUMPS")]
134 #[serde(default)]
135 #[default(true)]
136 pub disable_core_dumps: bool,
137
138 #[arg(
140 long = "constant-signature-counter",
141 env = "PASSLESS_CONSTANT_SIGNATURE_COUNTER",
142 action = ArgAction::Set,
143 require_equals = true,
144 num_args = 0..=1,
145 default_missing_value = "true"
146 )]
147 #[serde(default)]
148 #[default(false)]
149 pub constant_signature_counter: bool,
150
151 #[arg(
153 long = "user-verification-registration",
154 env = "PASSLESS_USER_VERIFICATION_REGISTRATION"
155 )]
156 #[serde(default)]
157 #[default(true)]
158 pub user_verification_registration: bool,
159
160 #[arg(
162 long = "user-verification-authentication",
163 env = "PASSLESS_USER_VERIFICATION_AUTHENTICATION"
164 )]
165 #[serde(default)]
166 #[default(true)]
167 pub user_verification_authentication: bool,
168
169 #[arg(
171 long = "notification-timeout",
172 env = "PASSLESS_NOTIFICATION_TIMEOUT",
173 value_name = "SECONDS"
174 )]
175 #[serde(default)]
176 #[default(30)]
177 pub notification_timeout: u32,
178}
179
180impl SecurityConfig {
181 pub fn apply_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
183 if self.disable_core_dumps {
184 self.disable_core_dumps_impl()?;
185 }
186 if self.check_mlock {
187 self.probe_mlock_capability()?;
188 }
189 Ok(())
190 }
191
192 fn disable_core_dumps_impl(&self) -> Result<(), Box<dyn std::error::Error>> {
194 debug!("Disabling core dumps to prevent credential leakage");
195 setrlimit(Resource::RLIMIT_CORE, 0, 0)?;
196 let r = unsafe { prctl(PR_SET_DUMPABLE, 0, 0, 0, 0) };
197 if r != 0 {
198 log::warn!("prctl(PR_SET_DUMPABLE) failed: {}", r);
199 }
200 Ok(())
201 }
202
203 fn probe_mlock_capability(&self) -> Result<(), Box<dyn std::error::Error>> {
205 debug!("Check mlock capability");
206
207 let test_size = 4096;
208 let test_buffer = vec![0u8; test_size];
209 let ptr = test_buffer.as_ptr() as *const libc::c_void;
210
211 let lock_result = unsafe { mlock(ptr, test_size) };
212
213 if lock_result == 0 {
214 unsafe { munlock(ptr, test_size) };
215 log::debug!("MLOCK is enabled - sensitive data will not be swapped to disk");
216 } else {
217 log::warn!(
218 "mlock capability probe failed - memory locking may not be available.\n\
219 Hint: grant CAP_IPC_LOCK to the binary with: 'sudo setcap cap_ipc_lock=+ep $(which passless)'"
220 );
221 }
222 Ok(())
223 }
224}
225
226#[derive(ClapSerde, Serialize, Deserialize, Debug, ConfigDoc)]
229pub struct AppConfig {
230 #[arg(short = 't', long = "backend-type", env = "PASSLESS_BACKEND_TYPE")]
232 #[serde(default)]
233 #[default("pass".to_string())]
234 pub backend_type: String,
235
236 #[arg(
239 short,
240 long,
241 env = "PASSLESS_VERBOSE",
242 action = ArgAction::Set,
243 require_equals = true,
244 num_args = 0..=1,
245 default_missing_value = "true"
246 )]
247 #[default(true)]
248 #[serde(default)]
249 pub verbose: bool,
250
251 #[clap_serde]
253 #[serde(default)]
254 #[command(flatten)]
255 pub pass: PassBackendConfig,
256
257 #[clap_serde]
259 #[serde(default)]
260 #[command(flatten)]
261 pub tpm: TpmBackendConfig,
262
263 #[clap_serde]
265 #[serde(default)]
266 #[command(flatten)]
267 pub local: LocalBackendConfig,
268
269 #[clap_serde]
271 #[serde(default)]
272 #[command(flatten)]
273 pub security: SecurityConfig,
274}
275
276#[derive(Debug, Clone)]
278pub enum BackendConfig {
279 Local {
280 path: String,
281 },
282 Pass {
283 store_path: String,
284 path: String,
285 gpg_backend: String,
286 },
287 Tpm {
288 path: String,
289 tcti: String,
290 },
291}
292
293impl AppConfig {
294 pub fn load(args: &mut Args) -> Self {
296 let default_config_path = dirs::config_dir().map(|p| p.join("passless/config.toml"));
298
299 let config_file_path = args
300 .config_path
301 .as_ref()
302 .or(default_config_path.as_ref())
303 .filter(|p| p.exists());
304
305 if let Some(path) = config_file_path
306 && let Ok(f) = File::open(path)
307 {
308 log::info!("Loading configuration from: {}", path.display());
309 let content = std::io::read_to_string(BufReader::new(f)).unwrap_or_default();
310 match toml::from_str::<AppConfig>(&content) {
311 Ok(file_config) => {
312 return file_config.merge(&mut args.config);
314 }
315 Err(e) => log::warn!("Failed to parse config file {}: {}", path.display(), e),
316 }
317 }
318
319 AppConfig::from(&mut args.config)
321 }
322
323 pub fn backend(&self) -> crate::error::Result<BackendConfig> {
325 match self.backend_type.as_str() {
326 "local" => Ok(BackendConfig::Local {
327 path: self.local.path.clone(),
328 }),
329 "pass" => Ok(BackendConfig::Pass {
330 store_path: self.pass.store_path.clone(),
331 path: self.pass.path.clone(),
332 gpg_backend: self.pass.gpg_backend.clone(),
333 }),
334 "tpm" => Ok(BackendConfig::Tpm {
335 path: self.tpm.path.clone(),
336 tcti: self.tpm.tcti.clone(),
337 }),
338 _ => Err(crate::error::Error::Config(format!(
339 "Invalid backend_type '{}'. Must be one of: local, pass, tpm",
340 self.backend_type
341 ))),
342 }
343 }
344
345 pub fn apply_security_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
347 self.security.apply_hardening()
348 }
349
350 pub fn security_config(&self) -> SecurityConfig {
352 self.security.clone()
353 }
354}
355
356#[derive(Parser)]
358#[command(author, version, about)]
359pub struct Args {
360 #[arg(short, long, env = "PASSLESS_CONFIG")]
362 pub config_path: Option<PathBuf>,
363
364 #[command(flatten)]
366 pub config: <AppConfig as ClapSerde>::Opt,
367
368 #[command(subcommand)]
370 pub command: Option<Commands>,
371}
372
373#[derive(Debug, Clone, Copy, PartialEq, Eq)]
375pub enum OutputFormat {
376 Plain,
378 Json,
380}
381
382impl std::str::FromStr for OutputFormat {
383 type Err = String;
384
385 fn from_str(s: &str) -> Result<Self, Self::Err> {
386 match s.to_lowercase().as_str() {
387 "plain" => Ok(OutputFormat::Plain),
388 "json" => Ok(OutputFormat::Json),
389 _ => Err(format!(
390 "Invalid output format '{}'. Must be 'plain' or 'json'",
391 s
392 )),
393 }
394 }
395}
396
397impl std::fmt::Display for OutputFormat {
398 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
399 match self {
400 OutputFormat::Plain => write!(f, "plain"),
401 OutputFormat::Json => write!(f, "json"),
402 }
403 }
404}
405
406#[derive(Subcommand, Debug, Clone)]
408pub enum Commands {
409 Config {
411 #[command(subcommand)]
412 action: ConfigAction,
413 },
414 Client {
420 #[arg(short = 'D', long = "device", value_name = "INDEX|NAME", global = true)]
422 device: Option<String>,
423
424 #[arg(
426 short = 'o',
427 long = "output",
428 value_name = "FORMAT",
429 default_value = "plain",
430 global = true
431 )]
432 output: OutputFormat,
433
434 #[command(subcommand)]
435 action: ClientAction,
436 },
437}
438
439#[derive(Subcommand, Debug, Clone)]
441pub enum ConfigAction {
442 Print,
444}
445
446#[derive(Subcommand, Debug, Clone)]
448pub enum ClientAction {
449 Devices,
451 Info,
453 Reset {
455 #[arg(long = "yes-i-really-want-to-reset-my-device", action = ArgAction::Count)]
457 confirm: u8,
458 },
459 List {
461 #[arg(short = 'd', long = "domain", value_name = "DOMAIN")]
463 rp_id: Option<String>,
464 },
465 Show {
467 #[arg(value_name = "CREDENTIAL_ID")]
469 credential_id: String,
470 },
471 Delete {
473 #[arg(value_name = "CREDENTIAL_ID")]
475 credential_id: String,
476 },
477 Rename {
479 #[arg(value_name = "CREDENTIAL_ID")]
481 credential_id: String,
482 #[arg(short = 'u', long = "user-name", value_name = "NAME")]
484 user_name: Option<String>,
485 #[arg(short = 'n', long = "display-name", value_name = "NAME")]
487 display_name: Option<String>,
488 },
489 Pin {
491 #[command(subcommand)]
492 action: PinAction,
493 },
494}
495
496#[derive(Subcommand, Debug, Clone)]
498pub enum PinAction {
499 Set {
501 #[arg(value_name = "PIN")]
503 pin: String,
504 },
505 Change {
507 #[arg(value_name = "OLD_PIN")]
509 old_pin: String,
510 #[arg(value_name = "NEW_PIN")]
512 new_pin: String,
513 },
514}