1use std::fs::{self, File};
9use std::io::BufReader;
10use std::path::{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(
280 long = "pin-max-uv-retries",
281 env = "PASSLESS_PIN_MAX_UV_RETRIES",
282 value_name = "RETRIES"
283 )]
284 #[serde(default)]
285 #[default(8)]
286 pub max_uv_retries: u8,
287
288 #[arg(
291 long = "pin-auto-lock-timeout",
292 env = "PASSLESS_PIN_AUTO_LOCK_TIMEOUT",
293 value_name = "SECONDS"
294 )]
295 #[serde(default)]
296 #[default(0)]
297 pub auto_lock_timeout: u32,
298}
299
300impl PinConfig {
301 pub fn validate(&self) -> crate::error::Result<()> {
303 if self.min_length < 4 || self.min_length > 63 {
304 return Err(crate::error::Error::Config(format!(
305 "pin.min_length must be between 4 and 63, got {}",
306 self.min_length
307 )));
308 }
309 if self.max_retries == 0 {
310 return Err(crate::error::Error::Config(
311 "pin.max_retries must be greater than 0".to_string(),
312 ));
313 }
314 if self.max_uv_retries == 0 {
315 return Err(crate::error::Error::Config(
316 "pin.max_uv_retries must be greater than 0".to_string(),
317 ));
318 }
319 Ok(())
320 }
321}
322
323impl SecurityConfig {
324 pub fn apply_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
326 if self.disable_core_dumps {
327 self.disable_core_dumps_impl()?;
328 }
329 if self.check_mlock {
330 self.probe_mlock_capability()?;
331 }
332 Ok(())
333 }
334
335 fn disable_core_dumps_impl(&self) -> Result<(), Box<dyn std::error::Error>> {
337 debug!("Disabling core dumps to prevent credential leakage");
338 setrlimit(Resource::RLIMIT_CORE, 0, 0)?;
339 let r = unsafe { prctl(PR_SET_DUMPABLE, 0, 0, 0, 0) };
340 if r != 0 {
341 log::warn!("prctl(PR_SET_DUMPABLE) failed: {}", r);
342 }
343 Ok(())
344 }
345
346 fn probe_mlock_capability(&self) -> Result<(), Box<dyn std::error::Error>> {
348 debug!("Check mlock capability");
349
350 let test_size = 4096;
351 let test_buffer = vec![0u8; test_size];
352 let ptr = test_buffer.as_ptr() as *const libc::c_void;
353
354 let lock_result = unsafe { mlock(ptr, test_size) };
355
356 if lock_result == 0 {
357 unsafe { munlock(ptr, test_size) };
358 log::debug!("MLOCK is enabled - sensitive data will not be swapped to disk");
359 } else {
360 log::warn!(
361 "mlock capability probe failed - memory locking may not be available.\n\
362 Hint: grant CAP_IPC_LOCK to the binary with: 'sudo setcap cap_ipc_lock=+ep $(which passless)'"
363 );
364 }
365 Ok(())
366 }
367}
368
369#[derive(ClapSerde, Serialize, Deserialize, Debug, ConfigDoc)]
372pub struct AppConfig {
373 #[arg(short = 't', long = "backend-type", env = "PASSLESS_BACKEND_TYPE")]
375 #[serde(default)]
376 #[default("pass".to_string())]
377 pub backend_type: String,
378
379 #[arg(
382 short,
383 long,
384 env = "PASSLESS_VERBOSE",
385 action = ArgAction::Set,
386 require_equals = true,
387 num_args = 0..=1,
388 default_missing_value = "true"
389 )]
390 #[default(true)]
391 #[serde(default)]
392 pub verbose: bool,
393
394 #[clap_serde]
396 #[serde(default)]
397 #[command(flatten)]
398 pub pass: PassBackendConfig,
399
400 #[cfg(feature = "tpm")]
402 #[clap_serde]
403 #[serde(default)]
404 #[command(flatten)]
405 pub tpm: TpmBackendConfig,
406
407 #[clap_serde]
409 #[serde(default)]
410 #[command(flatten)]
411 pub local: LocalBackendConfig,
412
413 #[clap_serde]
415 #[serde(default)]
416 #[command(flatten)]
417 pub security: SecurityConfig,
418
419 #[clap_serde]
421 #[serde(default)]
422 #[command(flatten)]
423 pub pin: PinConfig,
424}
425
426#[derive(Debug, Clone)]
428pub enum BackendConfig {
429 Local {
430 path: String,
431 },
432 Pass {
433 store_path: String,
434 path: String,
435 gpg_backend: String,
436 },
437 #[cfg(feature = "tpm")]
438 Tpm {
439 path: String,
440 tcti: String,
441 },
442}
443
444impl BackendConfig {
445 pub fn canonicalize_path(path: &Path) -> PathBuf {
450 match fs::canonicalize(path) {
451 Ok(p) => p,
452 Err(_) => {
453 let mut current = path.to_path_buf();
454 let mut suffix = Vec::new();
455 loop {
456 match fs::canonicalize(¤t) {
457 Ok(base) => {
458 let mut result = base;
459 for component in suffix.iter().rev() {
460 result.push(component);
461 }
462 return result;
463 }
464 Err(_) => {
465 if let Some(file_name) = current.file_name() {
466 suffix.push(file_name.to_os_string());
467 current = current
468 .parent()
469 .map(|p| p.to_path_buf())
470 .unwrap_or_default();
471 } else {
472 return path.to_path_buf();
473 }
474 }
475 }
476 }
477 }
478 }
479 }
480
481 pub fn state_path(&self) -> PathBuf {
486 match self {
487 BackendConfig::Local { path } => Self::canonicalize_path(Path::new(path)),
488 BackendConfig::Pass {
489 store_path, path, ..
490 } => Self::canonicalize_path(&Path::new(store_path).join(path)),
491 #[cfg(feature = "tpm")]
492 BackendConfig::Tpm { path, .. } => Self::canonicalize_path(Path::new(path)),
493 }
494 }
495
496 pub fn state_display(&self) -> String {
498 match self {
499 BackendConfig::Local { path } => path.clone(),
500 BackendConfig::Pass {
501 store_path, path, ..
502 } => {
503 format!("{}/{}", store_path, path)
504 }
505 #[cfg(feature = "tpm")]
506 BackendConfig::Tpm { path, .. } => path.clone(),
507 }
508 }
509}
510
511impl AppConfig {
512 pub fn load(args: &mut Args) -> Self {
514 let default_config_path = dirs::config_dir().map(|p| p.join("passless/config.toml"));
516
517 let config_file_path = args
518 .config_path
519 .as_ref()
520 .or(default_config_path.as_ref())
521 .filter(|p| p.exists());
522
523 if let Some(path) = config_file_path
524 && let Ok(f) = File::open(path)
525 {
526 log::info!("Loading configuration from: {}", path.display());
527 let content = std::io::read_to_string(BufReader::new(f)).unwrap_or_default();
528 match toml::from_str::<<AppConfig as ClapSerde>::Opt>(&content) {
529 Ok(file_config) => {
530 return AppConfig::from(file_config).merge(&mut args.config);
532 }
533 Err(e) => log::warn!("Failed to parse config file {}: {}", path.display(), e),
534 }
535 }
536
537 AppConfig::from(&mut args.config)
539 }
540
541 pub fn backend(&self) -> crate::error::Result<BackendConfig> {
543 match self.backend_type.as_str() {
544 "local" => Ok(BackendConfig::Local {
545 path: self.local.path.clone(),
546 }),
547 "pass" => Ok(BackendConfig::Pass {
548 store_path: self.pass.store_path.clone(),
549 path: self.pass.path.clone(),
550 gpg_backend: self.pass.gpg_backend.clone(),
551 }),
552 #[cfg(feature = "tpm")]
553 "tpm" => Ok(BackendConfig::Tpm {
554 path: self.tpm.path.clone(),
555 tcti: self.tpm.tcti.clone(),
556 }),
557 _ => Err(crate::error::Error::Config(format!(
558 "Invalid backend_type '{}'. Must be one of: local, pass, tpm",
559 self.backend_type
560 ))),
561 }
562 }
563
564 pub fn apply_security_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
566 self.security.apply_hardening()
567 }
568
569 pub fn security_config(&self) -> SecurityConfig {
571 self.security.clone()
572 }
573
574 pub fn pin_config(&self) -> PinConfig {
576 self.pin.clone()
577 }
578
579 pub fn validate(&self) -> crate::error::Result<()> {
581 self.pin.validate()?;
582 Ok(())
583 }
584}
585
586#[derive(Parser)]
588#[command(author, version, about)]
589pub struct Args {
590 #[arg(short, long, env = "PASSLESS_CONFIG")]
592 pub config_path: Option<PathBuf>,
593
594 #[command(flatten)]
596 pub config: <AppConfig as ClapSerde>::Opt,
597
598 #[command(subcommand)]
600 pub command: Option<Commands>,
601}
602
603#[derive(Debug, Clone, Copy, PartialEq, Eq)]
605pub enum OutputFormat {
606 Plain,
608 Json,
610}
611
612impl std::str::FromStr for OutputFormat {
613 type Err = String;
614
615 fn from_str(s: &str) -> Result<Self, Self::Err> {
616 match s.to_lowercase().as_str() {
617 "plain" => Ok(OutputFormat::Plain),
618 "json" => Ok(OutputFormat::Json),
619 _ => Err(format!(
620 "Invalid output format '{}'. Must be 'plain' or 'json'",
621 s
622 )),
623 }
624 }
625}
626
627impl std::fmt::Display for OutputFormat {
628 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
629 match self {
630 OutputFormat::Plain => write!(f, "plain"),
631 OutputFormat::Json => write!(f, "json"),
632 }
633 }
634}
635
636#[derive(Subcommand, Debug, Clone)]
638pub enum Commands {
639 Config {
641 #[command(subcommand)]
642 action: ConfigAction,
643 },
644 Client {
650 #[arg(short = 'D', long = "device", value_name = "INDEX|NAME", global = true)]
652 device: Option<String>,
653
654 #[arg(
656 short = 'o',
657 long = "output",
658 value_name = "FORMAT",
659 default_value = "plain",
660 global = true
661 )]
662 output: OutputFormat,
663
664 #[command(subcommand)]
665 action: ClientAction,
666 },
667}
668
669#[derive(Subcommand, Debug, Clone)]
671pub enum ConfigAction {
672 Print,
674}
675
676#[derive(Subcommand, Debug, Clone)]
678pub enum ClientAction {
679 Devices,
681 Info,
683 Reset {
685 #[arg(long = "yes-i-really-want-to-reset-my-device", action = ArgAction::Count)]
687 confirm: u8,
688 },
689 List {
691 #[arg(short = 'd', long = "domain", value_name = "DOMAIN")]
693 rp_id: Option<String>,
694 },
695 Show {
697 #[arg(value_name = "CREDENTIAL_ID")]
699 credential_id: String,
700 },
701 Delete {
703 #[arg(value_name = "CREDENTIAL_ID")]
705 credential_id: String,
706 },
707 Rename {
709 #[arg(value_name = "CREDENTIAL_ID")]
711 credential_id: String,
712 #[arg(short = 'u', long = "user-name", value_name = "NAME")]
714 user_name: Option<String>,
715 #[arg(short = 'n', long = "display-name", value_name = "NAME")]
717 display_name: Option<String>,
718 },
719 Pin {
721 #[command(subcommand)]
722 action: PinAction,
723 },
724}
725
726#[derive(Subcommand, Debug, Clone)]
728pub enum PinAction {
729 Set {
731 #[arg(value_name = "PIN")]
733 pin: String,
734 },
735 Change {
737 #[arg(value_name = "OLD_PIN")]
739 old_pin: String,
740 #[arg(value_name = "NEW_PIN")]
742 new_pin: String,
743 },
744 UvReset,
746}
747
748#[cfg(test)]
749mod tests {
750 use super::*;
751
752 #[test]
753 fn test_pin_config_default_max_uv_retries() {
754 let config = PinConfig {
755 enforcement: PinEnforcement::Optional,
756 min_length: 4,
757 max_retries: 8,
758 max_uv_retries: 8,
759 auto_lock_timeout: 0,
760 };
761 assert_eq!(config.max_uv_retries, 8);
762 }
763
764 #[test]
765 fn test_pin_config_validate_success() {
766 let config = PinConfig {
767 enforcement: PinEnforcement::Optional,
768 min_length: 4,
769 max_retries: 8,
770 max_uv_retries: 8,
771 auto_lock_timeout: 0,
772 };
773 assert!(config.validate().is_ok());
774 }
775
776 #[test]
777 fn test_pin_config_validate_zero_max_uv_retries() {
778 let config = PinConfig {
779 enforcement: PinEnforcement::Optional,
780 min_length: 4,
781 max_retries: 8,
782 max_uv_retries: 0,
783 auto_lock_timeout: 0,
784 };
785 let result = config.validate();
786 assert!(result.is_err());
787 assert!(result.unwrap_err().to_string().contains("max_uv_retries"));
788 }
789
790 #[test]
791 fn test_pin_config_validate_zero_max_retries() {
792 let config = PinConfig {
793 enforcement: PinEnforcement::Optional,
794 min_length: 4,
795 max_retries: 0,
796 max_uv_retries: 8,
797 auto_lock_timeout: 0,
798 };
799 let result = config.validate();
800 assert!(result.is_err());
801 assert!(result.unwrap_err().to_string().contains("max_retries"));
802 }
803
804 #[test]
805 fn test_pin_config_validate_invalid_min_length() {
806 let config = PinConfig {
807 enforcement: PinEnforcement::Optional,
808 min_length: 3,
809 max_retries: 8,
810 max_uv_retries: 8,
811 auto_lock_timeout: 0,
812 };
813 let result = config.validate();
814 assert!(result.is_err());
815 assert!(result.unwrap_err().to_string().contains("min_length"));
816 }
817
818 #[test]
819 fn test_canonicalize_path_existing() {
820 let dir = std::env::temp_dir();
821 let canonical = BackendConfig::canonicalize_path(&dir);
822 assert!(canonical.is_absolute());
823 assert!(canonical.exists());
824 }
825
826 #[test]
827 fn test_canonicalize_path_nonexistent() {
828 let base = std::env::temp_dir();
829 let nonexistent = base.join("passless_test_nonexistent_dir_12345/sub");
830 let canonical = BackendConfig::canonicalize_path(&nonexistent);
831 assert!(canonical.is_absolute());
832 assert!(canonical.starts_with(BackendConfig::canonicalize_path(&base)));
833 }
834
835 #[test]
836 fn test_canonicalize_path_symlink() {
837 let dir = tempfile::tempdir().unwrap();
838 let real = dir.path().join("real");
839 std::fs::create_dir(&real).unwrap();
840 let link = dir.path().join("link");
841 std::os::unix::fs::symlink(&real, &link).unwrap();
842
843 let canonical_real = BackendConfig::canonicalize_path(&real);
844 let canonical_link = BackendConfig::canonicalize_path(&link);
845 assert_eq!(canonical_real, canonical_link);
846 }
847
848 #[test]
849 fn test_local_state_path_relative_and_absolute() {
850 let dir = tempfile::tempdir_in(".").unwrap();
851 let abs_path = std::fs::canonicalize(dir.path()).unwrap();
852 let rel_path = dir.path().to_path_buf();
853
854 let backend_abs = BackendConfig::Local {
855 path: abs_path.display().to_string(),
856 };
857 let backend_rel = BackendConfig::Local {
858 path: rel_path.display().to_string(),
859 };
860 assert_eq!(backend_abs.state_path(), backend_rel.state_path());
861 }
862
863 #[test]
864 fn test_pass_state_path_different_subpaths() {
865 let store = "/tmp/passless_test_store";
866 let backend_a = BackendConfig::Pass {
867 store_path: store.to_string(),
868 path: "fido2".to_string(),
869 gpg_backend: "gnupg-bin".to_string(),
870 };
871 let backend_b = BackendConfig::Pass {
872 store_path: store.to_string(),
873 path: "fido2-other".to_string(),
874 gpg_backend: "gnupg-bin".to_string(),
875 };
876 assert_ne!(backend_a.state_path(), backend_b.state_path());
877 }
878
879 #[test]
880 fn test_different_local_paths_produce_different_identities() {
881 let backend_a = BackendConfig::Local {
882 path: "/tmp/passless_a".to_string(),
883 };
884 let backend_b = BackendConfig::Local {
885 path: "/tmp/passless_b".to_string(),
886 };
887 assert_ne!(backend_a.state_path(), backend_b.state_path());
888 }
889}