Skip to main content

passless_core/
config.rs

1//! Application configuration using clap-serde-derive
2//!
3//! This module provides a unified configuration approach where settings can come from:
4//! 1. CLI arguments (highest priority)
5//! 2. Configuration file (medium priority)
6//! 3. Default values (lowest priority)
7
8use 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
21/// Compute default local storage path
22pub 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/// Local backend configuration
31#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
32#[group(id = "local-backend-config")]
33pub struct LocalBackendConfig {
34    /// Path to local storage directory
35    #[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
46/// Compute default password-store path
47pub 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/// Pass (password-store) backend configuration
55#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
56#[group(id = "pass-backend-config")]
57pub struct PassBackendConfig {
58    /// Path to password store directory
59    #[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    /// Relative path within password store for FIDO2 entries
70    #[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    /// GPG backend: "gpgme" or "gnupg-bin"
81    #[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
91/// Compute default TPM storage path
92pub 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/// TPM backend configuration
101#[cfg(feature = "tpm")]
102#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
103#[group(id = "tpm-backend-config")]
104pub struct TpmBackendConfig {
105    /// Path to TPM storage directory
106    #[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    /// TPM TCTI (TPM Command Transmission Interface) configuration
117    #[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/// Security configuration
124#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
125#[group(id = "security")]
126pub struct SecurityConfig {
127    /// Check if mlock is available to prevent credentials from being swapped to disk
128    #[arg(long = "check-mlock", env = "PASSLESS_CHECK_MLOCK")]
129    #[serde(default)]
130    #[default(true)]
131    pub check_mlock: bool,
132
133    /// Disable core dumps to prevent credential leakage
134    #[arg(long = "disable-core-dumps", env = "PASSLESS_DISABLE_CORE_DUMPS")]
135    #[serde(default)]
136    #[default(true)]
137    pub disable_core_dumps: bool,
138
139    /// Enable constant signature counter to help RPs detect cloned authenticators
140    #[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    /// Always require user verification for all operations
152    /// - When PIN is set + pin.enforcement="required": requires PIN
153    /// - When PIN is set + pin.enforcement="optional": depends on context
154    /// - When PIN is set + pin.enforcement="never": uses notification fallback
155    /// - When PIN not set: uses notification
156    #[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    /// Show user verification notification during registration
170    #[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    /// Show user verification notification during authentication
179    #[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    /// Notification timeout in seconds (0 = no timeout)
188    #[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/// PIN enforcement policy
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
200#[serde(rename_all = "lowercase")]
201pub enum PinEnforcement {
202    /// Never require PIN, always use notification fallback (backward compatible)
203    Never,
204    /// Use PIN only when always_uv=true or client requests UV
205    #[default]
206    Optional,
207    /// Always require PIN when set (most secure)
208    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/// PIN configuration
238#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
239#[group(id = "pin")]
240pub struct PinConfig {
241    /// PIN enforcement policy when PIN is set:
242    /// - "never": Always use notification fallback (backward compatible, convenience)
243    /// - "optional": Use PIN only when always_uv=true or client requests UV
244    /// - "required": Always require PIN when set (most secure)
245    #[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    /// Minimum PIN length in characters (CTAP spec: 4-63)
255    #[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    /// Maximum PIN retry attempts before lockout (CTAP spec: 8)
265    #[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    /// Auto-lock timeout in seconds after max failed attempts (0 = disabled)
275    /// After lockout, authenticator must be reset to use PIN again
276    #[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    /// Apply security hardening measures
288    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    /// Disable core dumps to prevent credential leakage
299    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    /// Probe mlock capability by testing with a small allocation
310    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/// Main application configuration
333/// Note: Cannot derive Clone/Debug because it has #[clap_serde] fields
334#[derive(ClapSerde, Serialize, Deserialize, Debug, ConfigDoc)]
335pub struct AppConfig {
336    /// Storage backend type: pass, tpm (experimental), or local (for testing)
337    #[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    /// Enable verbose logging
343    // workaround for allowing `-v` syntax instead of `-v=true`
344    #[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    /// Pass backend configuration
358    #[clap_serde]
359    #[serde(default)]
360    #[command(flatten)]
361    pub pass: PassBackendConfig,
362
363    /// TPM backend configuration
364    #[cfg(feature = "tpm")]
365    #[clap_serde]
366    #[serde(default)]
367    #[command(flatten)]
368    pub tpm: TpmBackendConfig,
369
370    /// Local backend configuration
371    #[clap_serde]
372    #[serde(default)]
373    #[command(flatten)]
374    pub local: LocalBackendConfig,
375
376    /// Security hardening configuration
377    #[clap_serde]
378    #[serde(default)]
379    #[command(flatten)]
380    pub security: SecurityConfig,
381
382    /// PIN configuration
383    #[clap_serde]
384    #[serde(default)]
385    #[command(flatten)]
386    pub pin: PinConfig,
387}
388
389/// Backend-specific configuration
390#[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    /// Load configuration with precedence: CLI > config file > defaults
409    pub fn load(args: &mut Args) -> Self {
410        // Try to load config file
411        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                    // Deserialize into Opt, then convert with defaults and merge CLI args
427                    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        // No config file or parse failed - use CLI args + defaults
434        AppConfig::from(&mut args.config)
435    }
436
437    /// Get the backend configuration based on the backend_type
438    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    /// Apply security hardening measures
461    pub fn apply_security_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
462        self.security.apply_hardening()
463    }
464
465    /// Get security configuration
466    pub fn security_config(&self) -> SecurityConfig {
467        self.security.clone()
468    }
469
470    /// Get PIN configuration
471    pub fn pin_config(&self) -> PinConfig {
472        self.pin.clone()
473    }
474}
475
476/// CLI arguments structure
477#[derive(Parser)]
478#[command(author, version, about)]
479pub struct Args {
480    /// Path to configuration file (TOML format)
481    #[arg(short, long, env = "PASSLESS_CONFIG")]
482    pub config_path: Option<PathBuf>,
483
484    /// Application configuration (can come from CLI or config file)
485    #[command(flatten)]
486    pub config: <AppConfig as ClapSerde>::Opt,
487
488    /// Subcommands
489    #[command(subcommand)]
490    pub command: Option<Commands>,
491}
492
493/// Output format for client commands
494#[derive(Debug, Clone, Copy, PartialEq, Eq)]
495pub enum OutputFormat {
496    /// Human-readable plain text output
497    Plain,
498    /// JSON output for programmatic consumption
499    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/// Subcommands for passless
527#[derive(Subcommand, Debug, Clone)]
528pub enum Commands {
529    /// Configuration management commands
530    Config {
531        #[command(subcommand)]
532        action: ConfigAction,
533    },
534    /// FIDO2 client commands for managing authenticators
535    ///
536    /// These commands require a running authenticator. For testing:
537    /// 1. Start authenticator: PASSLESS_E2E_AUTO_ACCEPT_UV=1 cargo run -- --backend-type local
538    /// 2. Run client commands in another terminal with the same environment variable
539    Client {
540        /// Select device by index (0-based) or name. Use 'devices' subcommand to list available devices.
541        #[arg(short = 'D', long = "device", value_name = "INDEX|NAME", global = true)]
542        device: Option<String>,
543
544        /// Output format: plain (default) or json
545        #[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/// Configuration actions
560#[derive(Subcommand, Debug, Clone)]
561pub enum ConfigAction {
562    /// Print the default configuration in TOML format
563    Print,
564}
565
566/// Client actions for FIDO2 authenticator management
567#[derive(Subcommand, Debug, Clone)]
568pub enum ClientAction {
569    /// List all available FIDO2 authenticators/devices
570    Devices,
571    /// Get authenticator information (capabilities, AAGUID, versions, etc.)
572    Info,
573    /// Reset the authenticator (WARNING: deletes ALL credentials)
574    Reset {
575        /// Confirmation flag that must be provided twice for safety
576        #[arg(long = "yes-i-really-want-to-reset-my-device", action = ArgAction::Count)]
577        confirm: u8,
578    },
579    /// List all credentials on the authenticator
580    List {
581        /// Filter by relying party ID (domain)
582        #[arg(short = 'd', long = "domain", value_name = "DOMAIN")]
583        rp_id: Option<String>,
584    },
585    /// Show detailed information about a specific credential
586    Show {
587        /// Credential ID in hexadecimal format
588        #[arg(value_name = "CREDENTIAL_ID")]
589        credential_id: String,
590    },
591    /// Delete a specific credential by ID
592    Delete {
593        /// Credential ID in hexadecimal format
594        #[arg(value_name = "CREDENTIAL_ID")]
595        credential_id: String,
596    },
597    /// Rename a credential (update user name and/or display name)
598    Rename {
599        /// Credential ID in hexadecimal format
600        #[arg(value_name = "CREDENTIAL_ID")]
601        credential_id: String,
602        /// New user name (login identifier)
603        #[arg(short = 'u', long = "user-name", value_name = "NAME")]
604        user_name: Option<String>,
605        /// New display name (friendly name)
606        #[arg(short = 'n', long = "display-name", value_name = "NAME")]
607        display_name: Option<String>,
608    },
609    /// PIN management commands
610    Pin {
611        #[command(subcommand)]
612        action: PinAction,
613    },
614}
615
616/// PIN management actions
617#[derive(Subcommand, Debug, Clone)]
618pub enum PinAction {
619    /// Set a new PIN (authenticator must not have a PIN set)
620    Set {
621        /// The new PIN (minimum 4 characters)
622        #[arg(value_name = "PIN")]
623        pin: String,
624    },
625    /// Change the existing PIN
626    Change {
627        /// The current PIN
628        #[arg(value_name = "OLD_PIN")]
629        old_pin: String,
630        /// The new PIN (minimum 4 characters)
631        #[arg(value_name = "NEW_PIN")]
632        new_pin: String,
633    },
634}