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#[cfg(feature = "tpm")]
102#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
103#[group(id = "tpm-backend-config")]
104pub struct TpmBackendConfig {
105 #[arg(
107 long = "tpm-path",
108 env = "PASSLESS_TPM_PATH",
109 id = "tpm-path",
110 value_name = "PATH"
111 )]
112 #[serde(default)]
113 #[default(tpm_path())]
114 pub path: String,
115
116 #[arg(long = "tpm-tcti", env = "PASSLESS_TPM_TCTI", value_name = "TCTI")]
118 #[serde(default)]
119 #[default("device:/dev/tpmrm0".to_string())]
120 pub tcti: String,
121}
122
123#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
125#[group(id = "security")]
126pub struct SecurityConfig {
127 #[arg(long = "check-mlock", env = "PASSLESS_CHECK_MLOCK")]
129 #[serde(default)]
130 #[default(true)]
131 pub check_mlock: bool,
132
133 #[arg(long = "disable-core-dumps", env = "PASSLESS_DISABLE_CORE_DUMPS")]
135 #[serde(default)]
136 #[default(true)]
137 pub disable_core_dumps: bool,
138
139 #[arg(
141 long = "constant-signature-counter",
142 env = "PASSLESS_CONSTANT_SIGNATURE_COUNTER",
143 action = ArgAction::Set,
144 require_equals = true,
145 num_args = 0..=1,
146 default_missing_value = "true"
147 )]
148 #[serde(default)]
149 pub constant_signature_counter: bool,
150
151 #[arg(
157 long = "always-uv",
158 env = "PASSLESS_ALWAYS_UV",
159 action = ArgAction::Set,
160 require_equals = true,
161 num_args = 0..=1,
162 default_value = "true",
163 default_missing_value = "true"
164 )]
165 #[serde(default)]
166 #[default(true)]
167 pub always_uv: bool,
168
169 #[arg(
171 long = "user-verification-registration",
172 env = "PASSLESS_USER_VERIFICATION_REGISTRATION"
173 )]
174 #[serde(default)]
175 #[default(true)]
176 pub user_verification_registration: bool,
177
178 #[arg(
180 long = "user-verification-authentication",
181 env = "PASSLESS_USER_VERIFICATION_AUTHENTICATION"
182 )]
183 #[serde(default)]
184 #[default(true)]
185 pub user_verification_authentication: bool,
186
187 #[arg(
189 long = "notification-timeout",
190 env = "PASSLESS_NOTIFICATION_TIMEOUT",
191 value_name = "SECONDS"
192 )]
193 #[serde(default)]
194 #[default(30)]
195 pub notification_timeout: u32,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
200#[serde(rename_all = "lowercase")]
201pub enum PinEnforcement {
202 Never,
204 #[default]
206 Optional,
207 Required,
209}
210
211impl std::str::FromStr for PinEnforcement {
212 type Err = String;
213
214 fn from_str(s: &str) -> Result<Self, Self::Err> {
215 match s.to_lowercase().as_str() {
216 "never" => Ok(PinEnforcement::Never),
217 "optional" => Ok(PinEnforcement::Optional),
218 "required" => Ok(PinEnforcement::Required),
219 _ => Err(format!(
220 "Invalid PIN enforcement '{}'. Must be: never, optional, or required",
221 s
222 )),
223 }
224 }
225}
226
227impl std::fmt::Display for PinEnforcement {
228 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229 match self {
230 PinEnforcement::Never => write!(f, "never"),
231 PinEnforcement::Optional => write!(f, "optional"),
232 PinEnforcement::Required => write!(f, "required"),
233 }
234 }
235}
236
237#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
239#[group(id = "pin")]
240pub struct PinConfig {
241 #[arg(
246 long = "pin-enforcement",
247 env = "PASSLESS_PIN_ENFORCEMENT",
248 value_name = "POLICY"
249 )]
250 #[serde(default)]
251 #[default(PinEnforcement::Optional)]
252 pub enforcement: PinEnforcement,
253
254 #[arg(
256 long = "pin-min-length",
257 env = "PASSLESS_PIN_MIN_LENGTH",
258 value_name = "LENGTH"
259 )]
260 #[serde(default)]
261 #[default(4)]
262 pub min_length: u8,
263
264 #[arg(
266 long = "pin-max-retries",
267 env = "PASSLESS_PIN_MAX_RETRIES",
268 value_name = "RETRIES"
269 )]
270 #[serde(default)]
271 #[default(8)]
272 pub max_retries: u8,
273
274 #[arg(
277 long = "pin-auto-lock-timeout",
278 env = "PASSLESS_PIN_AUTO_LOCK_TIMEOUT",
279 value_name = "SECONDS"
280 )]
281 #[serde(default)]
282 #[default(0)]
283 pub auto_lock_timeout: u32,
284}
285
286impl SecurityConfig {
287 pub fn apply_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
289 if self.disable_core_dumps {
290 self.disable_core_dumps_impl()?;
291 }
292 if self.check_mlock {
293 self.probe_mlock_capability()?;
294 }
295 Ok(())
296 }
297
298 fn disable_core_dumps_impl(&self) -> Result<(), Box<dyn std::error::Error>> {
300 debug!("Disabling core dumps to prevent credential leakage");
301 setrlimit(Resource::RLIMIT_CORE, 0, 0)?;
302 let r = unsafe { prctl(PR_SET_DUMPABLE, 0, 0, 0, 0) };
303 if r != 0 {
304 log::warn!("prctl(PR_SET_DUMPABLE) failed: {}", r);
305 }
306 Ok(())
307 }
308
309 fn probe_mlock_capability(&self) -> Result<(), Box<dyn std::error::Error>> {
311 debug!("Check mlock capability");
312
313 let test_size = 4096;
314 let test_buffer = vec![0u8; test_size];
315 let ptr = test_buffer.as_ptr() as *const libc::c_void;
316
317 let lock_result = unsafe { mlock(ptr, test_size) };
318
319 if lock_result == 0 {
320 unsafe { munlock(ptr, test_size) };
321 log::debug!("MLOCK is enabled - sensitive data will not be swapped to disk");
322 } else {
323 log::warn!(
324 "mlock capability probe failed - memory locking may not be available.\n\
325 Hint: grant CAP_IPC_LOCK to the binary with: 'sudo setcap cap_ipc_lock=+ep $(which passless)'"
326 );
327 }
328 Ok(())
329 }
330}
331
332#[derive(ClapSerde, Serialize, Deserialize, Debug, ConfigDoc)]
335pub struct AppConfig {
336 #[arg(short = 't', long = "backend-type", env = "PASSLESS_BACKEND_TYPE")]
338 #[serde(default)]
339 #[default("pass".to_string())]
340 pub backend_type: String,
341
342 #[arg(
345 short,
346 long,
347 env = "PASSLESS_VERBOSE",
348 action = ArgAction::Set,
349 require_equals = true,
350 num_args = 0..=1,
351 default_missing_value = "true"
352 )]
353 #[default(true)]
354 #[serde(default)]
355 pub verbose: bool,
356
357 #[clap_serde]
359 #[serde(default)]
360 #[command(flatten)]
361 pub pass: PassBackendConfig,
362
363 #[cfg(feature = "tpm")]
365 #[clap_serde]
366 #[serde(default)]
367 #[command(flatten)]
368 pub tpm: TpmBackendConfig,
369
370 #[clap_serde]
372 #[serde(default)]
373 #[command(flatten)]
374 pub local: LocalBackendConfig,
375
376 #[clap_serde]
378 #[serde(default)]
379 #[command(flatten)]
380 pub security: SecurityConfig,
381
382 #[clap_serde]
384 #[serde(default)]
385 #[command(flatten)]
386 pub pin: PinConfig,
387}
388
389#[derive(Debug, Clone)]
391pub enum BackendConfig {
392 Local {
393 path: String,
394 },
395 Pass {
396 store_path: String,
397 path: String,
398 gpg_backend: String,
399 },
400 #[cfg(feature = "tpm")]
401 Tpm {
402 path: String,
403 tcti: String,
404 },
405}
406
407impl AppConfig {
408 pub fn load(args: &mut Args) -> Self {
410 let default_config_path = dirs::config_dir().map(|p| p.join("passless/config.toml"));
412
413 let config_file_path = args
414 .config_path
415 .as_ref()
416 .or(default_config_path.as_ref())
417 .filter(|p| p.exists());
418
419 if let Some(path) = config_file_path
420 && let Ok(f) = File::open(path)
421 {
422 log::info!("Loading configuration from: {}", path.display());
423 let content = std::io::read_to_string(BufReader::new(f)).unwrap_or_default();
424 match toml::from_str::<<AppConfig as ClapSerde>::Opt>(&content) {
425 Ok(file_config) => {
426 return AppConfig::from(file_config).merge(&mut args.config);
428 }
429 Err(e) => log::warn!("Failed to parse config file {}: {}", path.display(), e),
430 }
431 }
432
433 AppConfig::from(&mut args.config)
435 }
436
437 pub fn backend(&self) -> crate::error::Result<BackendConfig> {
439 match self.backend_type.as_str() {
440 "local" => Ok(BackendConfig::Local {
441 path: self.local.path.clone(),
442 }),
443 "pass" => Ok(BackendConfig::Pass {
444 store_path: self.pass.store_path.clone(),
445 path: self.pass.path.clone(),
446 gpg_backend: self.pass.gpg_backend.clone(),
447 }),
448 #[cfg(feature = "tpm")]
449 "tpm" => Ok(BackendConfig::Tpm {
450 path: self.tpm.path.clone(),
451 tcti: self.tpm.tcti.clone(),
452 }),
453 _ => Err(crate::error::Error::Config(format!(
454 "Invalid backend_type '{}'. Must be one of: local, pass, tpm",
455 self.backend_type
456 ))),
457 }
458 }
459
460 pub fn apply_security_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
462 self.security.apply_hardening()
463 }
464
465 pub fn security_config(&self) -> SecurityConfig {
467 self.security.clone()
468 }
469
470 pub fn pin_config(&self) -> PinConfig {
472 self.pin.clone()
473 }
474}
475
476#[derive(Parser)]
478#[command(author, version, about)]
479pub struct Args {
480 #[arg(short, long, env = "PASSLESS_CONFIG")]
482 pub config_path: Option<PathBuf>,
483
484 #[command(flatten)]
486 pub config: <AppConfig as ClapSerde>::Opt,
487
488 #[command(subcommand)]
490 pub command: Option<Commands>,
491}
492
493#[derive(Debug, Clone, Copy, PartialEq, Eq)]
495pub enum OutputFormat {
496 Plain,
498 Json,
500}
501
502impl std::str::FromStr for OutputFormat {
503 type Err = String;
504
505 fn from_str(s: &str) -> Result<Self, Self::Err> {
506 match s.to_lowercase().as_str() {
507 "plain" => Ok(OutputFormat::Plain),
508 "json" => Ok(OutputFormat::Json),
509 _ => Err(format!(
510 "Invalid output format '{}'. Must be 'plain' or 'json'",
511 s
512 )),
513 }
514 }
515}
516
517impl std::fmt::Display for OutputFormat {
518 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
519 match self {
520 OutputFormat::Plain => write!(f, "plain"),
521 OutputFormat::Json => write!(f, "json"),
522 }
523 }
524}
525
526#[derive(Subcommand, Debug, Clone)]
528pub enum Commands {
529 Config {
531 #[command(subcommand)]
532 action: ConfigAction,
533 },
534 Client {
540 #[arg(short = 'D', long = "device", value_name = "INDEX|NAME", global = true)]
542 device: Option<String>,
543
544 #[arg(
546 short = 'o',
547 long = "output",
548 value_name = "FORMAT",
549 default_value = "plain",
550 global = true
551 )]
552 output: OutputFormat,
553
554 #[command(subcommand)]
555 action: ClientAction,
556 },
557}
558
559#[derive(Subcommand, Debug, Clone)]
561pub enum ConfigAction {
562 Print,
564}
565
566#[derive(Subcommand, Debug, Clone)]
568pub enum ClientAction {
569 Devices,
571 Info,
573 Reset {
575 #[arg(long = "yes-i-really-want-to-reset-my-device", action = ArgAction::Count)]
577 confirm: u8,
578 },
579 List {
581 #[arg(short = 'd', long = "domain", value_name = "DOMAIN")]
583 rp_id: Option<String>,
584 },
585 Show {
587 #[arg(value_name = "CREDENTIAL_ID")]
589 credential_id: String,
590 },
591 Delete {
593 #[arg(value_name = "CREDENTIAL_ID")]
595 credential_id: String,
596 },
597 Rename {
599 #[arg(value_name = "CREDENTIAL_ID")]
601 credential_id: String,
602 #[arg(short = 'u', long = "user-name", value_name = "NAME")]
604 user_name: Option<String>,
605 #[arg(short = 'n', long = "display-name", value_name = "NAME")]
607 display_name: Option<String>,
608 },
609 Pin {
611 #[command(subcommand)]
612 action: PinAction,
613 },
614}
615
616#[derive(Subcommand, Debug, Clone)]
618pub enum PinAction {
619 Set {
621 #[arg(value_name = "PIN")]
623 pin: String,
624 },
625 Change {
627 #[arg(value_name = "OLD_PIN")]
629 old_pin: String,
630 #[arg(value_name = "NEW_PIN")]
632 new_pin: String,
633 },
634}