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    #[default(false)]
150    pub constant_signature_counter: bool,
151
152    /// Show user verification notification during registration
153    #[arg(
154        long = "user-verification-registration",
155        env = "PASSLESS_USER_VERIFICATION_REGISTRATION"
156    )]
157    #[serde(default)]
158    #[default(true)]
159    pub user_verification_registration: bool,
160
161    /// Show user verification notification during authentication
162    #[arg(
163        long = "user-verification-authentication",
164        env = "PASSLESS_USER_VERIFICATION_AUTHENTICATION"
165    )]
166    #[serde(default)]
167    #[default(true)]
168    pub user_verification_authentication: bool,
169
170    /// Notification timeout in seconds (0 = no timeout)
171    #[arg(
172        long = "notification-timeout",
173        env = "PASSLESS_NOTIFICATION_TIMEOUT",
174        value_name = "SECONDS"
175    )]
176    #[serde(default)]
177    #[default(30)]
178    pub notification_timeout: u32,
179}
180
181impl SecurityConfig {
182    /// Apply security hardening measures
183    pub fn apply_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
184        if self.disable_core_dumps {
185            self.disable_core_dumps_impl()?;
186        }
187        if self.check_mlock {
188            self.probe_mlock_capability()?;
189        }
190        Ok(())
191    }
192
193    /// Disable core dumps to prevent credential leakage
194    fn disable_core_dumps_impl(&self) -> Result<(), Box<dyn std::error::Error>> {
195        debug!("Disabling core dumps to prevent credential leakage");
196        setrlimit(Resource::RLIMIT_CORE, 0, 0)?;
197        let r = unsafe { prctl(PR_SET_DUMPABLE, 0, 0, 0, 0) };
198        if r != 0 {
199            log::warn!("prctl(PR_SET_DUMPABLE) failed: {}", r);
200        }
201        Ok(())
202    }
203
204    /// Probe mlock capability by testing with a small allocation
205    fn probe_mlock_capability(&self) -> Result<(), Box<dyn std::error::Error>> {
206        debug!("Check mlock capability");
207
208        let test_size = 4096;
209        let test_buffer = vec![0u8; test_size];
210        let ptr = test_buffer.as_ptr() as *const libc::c_void;
211
212        let lock_result = unsafe { mlock(ptr, test_size) };
213
214        if lock_result == 0 {
215            unsafe { munlock(ptr, test_size) };
216            log::debug!("MLOCK is enabled - sensitive data will not be swapped to disk");
217        } else {
218            log::warn!(
219                "mlock capability probe failed - memory locking may not be available.\n\
220                 Hint: grant CAP_IPC_LOCK to the binary with: 'sudo setcap cap_ipc_lock=+ep $(which passless)'"
221            );
222        }
223        Ok(())
224    }
225}
226
227/// Main application configuration
228/// Note: Cannot derive Clone/Debug because it has #[clap_serde] fields
229#[derive(ClapSerde, Serialize, Deserialize, Debug, ConfigDoc)]
230pub struct AppConfig {
231    /// Storage backend type: pass, tpm (experimental), or local (for testing)
232    #[arg(short = 't', long = "backend-type", env = "PASSLESS_BACKEND_TYPE")]
233    #[serde(default)]
234    #[default("pass".to_string())]
235    pub backend_type: String,
236
237    /// Enable verbose logging
238    // workaround for allowing `-v` syntax instead of `-v=true`
239    #[arg(
240        short,
241        long,
242        env = "PASSLESS_VERBOSE",
243        action = ArgAction::Set,
244        require_equals = true,
245        num_args = 0..=1,
246        default_missing_value = "true"
247    )]
248    #[default(true)]
249    #[serde(default)]
250    pub verbose: bool,
251
252    /// Pass backend configuration
253    #[clap_serde]
254    #[serde(default)]
255    #[command(flatten)]
256    pub pass: PassBackendConfig,
257
258    /// TPM backend configuration
259    #[cfg(feature = "tpm")]
260    #[clap_serde]
261    #[serde(default)]
262    #[command(flatten)]
263    pub tpm: TpmBackendConfig,
264
265    /// Local backend configuration
266    #[clap_serde]
267    #[serde(default)]
268    #[command(flatten)]
269    pub local: LocalBackendConfig,
270
271    /// Security hardening configuration
272    #[clap_serde]
273    #[serde(default)]
274    #[command(flatten)]
275    pub security: SecurityConfig,
276}
277
278/// Backend-specific configuration
279#[derive(Debug, Clone)]
280pub enum BackendConfig {
281    Local {
282        path: String,
283    },
284    Pass {
285        store_path: String,
286        path: String,
287        gpg_backend: String,
288    },
289    #[cfg(feature = "tpm")]
290    Tpm {
291        path: String,
292        tcti: String,
293    },
294}
295
296impl AppConfig {
297    /// Load configuration with precedence: CLI > config file > defaults
298    pub fn load(args: &mut Args) -> Self {
299        // Try to load config file
300        let default_config_path = dirs::config_dir().map(|p| p.join("passless/config.toml"));
301
302        let config_file_path = args
303            .config_path
304            .as_ref()
305            .or(default_config_path.as_ref())
306            .filter(|p| p.exists());
307
308        if let Some(path) = config_file_path
309            && let Ok(f) = File::open(path)
310        {
311            log::info!("Loading configuration from: {}", path.display());
312            let content = std::io::read_to_string(BufReader::new(f)).unwrap_or_default();
313            match toml::from_str::<AppConfig>(&content) {
314                Ok(file_config) => {
315                    // Merge: file config + CLI args (CLI takes precedence)
316                    return file_config.merge(&mut args.config);
317                }
318                Err(e) => log::warn!("Failed to parse config file {}: {}", path.display(), e),
319            }
320        }
321
322        // No config file or parse failed - use CLI args + defaults
323        AppConfig::from(&mut args.config)
324    }
325
326    /// Get the backend configuration based on the backend_type
327    pub fn backend(&self) -> crate::error::Result<BackendConfig> {
328        match self.backend_type.as_str() {
329            "local" => Ok(BackendConfig::Local {
330                path: self.local.path.clone(),
331            }),
332            "pass" => Ok(BackendConfig::Pass {
333                store_path: self.pass.store_path.clone(),
334                path: self.pass.path.clone(),
335                gpg_backend: self.pass.gpg_backend.clone(),
336            }),
337            #[cfg(feature = "tpm")]
338            "tpm" => Ok(BackendConfig::Tpm {
339                path: self.tpm.path.clone(),
340                tcti: self.tpm.tcti.clone(),
341            }),
342            _ => Err(crate::error::Error::Config(format!(
343                "Invalid backend_type '{}'. Must be one of: local, pass, tpm",
344                self.backend_type
345            ))),
346        }
347    }
348
349    /// Apply security hardening measures
350    pub fn apply_security_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
351        self.security.apply_hardening()
352    }
353
354    /// Get security configuration
355    pub fn security_config(&self) -> SecurityConfig {
356        self.security.clone()
357    }
358}
359
360/// CLI arguments structure
361#[derive(Parser)]
362#[command(author, version, about)]
363pub struct Args {
364    /// Path to configuration file (TOML format)
365    #[arg(short, long, env = "PASSLESS_CONFIG")]
366    pub config_path: Option<PathBuf>,
367
368    /// Application configuration (can come from CLI or config file)
369    #[command(flatten)]
370    pub config: <AppConfig as ClapSerde>::Opt,
371
372    /// Subcommands
373    #[command(subcommand)]
374    pub command: Option<Commands>,
375}
376
377/// Output format for client commands
378#[derive(Debug, Clone, Copy, PartialEq, Eq)]
379pub enum OutputFormat {
380    /// Human-readable plain text output
381    Plain,
382    /// JSON output for programmatic consumption
383    Json,
384}
385
386impl std::str::FromStr for OutputFormat {
387    type Err = String;
388
389    fn from_str(s: &str) -> Result<Self, Self::Err> {
390        match s.to_lowercase().as_str() {
391            "plain" => Ok(OutputFormat::Plain),
392            "json" => Ok(OutputFormat::Json),
393            _ => Err(format!(
394                "Invalid output format '{}'. Must be 'plain' or 'json'",
395                s
396            )),
397        }
398    }
399}
400
401impl std::fmt::Display for OutputFormat {
402    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
403        match self {
404            OutputFormat::Plain => write!(f, "plain"),
405            OutputFormat::Json => write!(f, "json"),
406        }
407    }
408}
409
410/// Subcommands for passless
411#[derive(Subcommand, Debug, Clone)]
412pub enum Commands {
413    /// Configuration management commands
414    Config {
415        #[command(subcommand)]
416        action: ConfigAction,
417    },
418    /// FIDO2 client commands for managing authenticators
419    ///
420    /// These commands require a running authenticator. For testing:
421    /// 1. Start authenticator: PASSLESS_E2E_AUTO_ACCEPT_UV=1 cargo run -- --backend-type local
422    /// 2. Run client commands in another terminal with the same environment variable
423    Client {
424        /// Select device by index (0-based) or name. Use 'devices' subcommand to list available devices.
425        #[arg(short = 'D', long = "device", value_name = "INDEX|NAME", global = true)]
426        device: Option<String>,
427
428        /// Output format: plain (default) or json
429        #[arg(
430            short = 'o',
431            long = "output",
432            value_name = "FORMAT",
433            default_value = "plain",
434            global = true
435        )]
436        output: OutputFormat,
437
438        #[command(subcommand)]
439        action: ClientAction,
440    },
441}
442
443/// Configuration actions
444#[derive(Subcommand, Debug, Clone)]
445pub enum ConfigAction {
446    /// Print the default configuration in TOML format
447    Print,
448}
449
450/// Client actions for FIDO2 authenticator management
451#[derive(Subcommand, Debug, Clone)]
452pub enum ClientAction {
453    /// List all available FIDO2 authenticators/devices
454    Devices,
455    /// Get authenticator information (capabilities, AAGUID, versions, etc.)
456    Info,
457    /// Reset the authenticator (WARNING: deletes ALL credentials)
458    Reset {
459        /// Confirmation flag that must be provided twice for safety
460        #[arg(long = "yes-i-really-want-to-reset-my-device", action = ArgAction::Count)]
461        confirm: u8,
462    },
463    /// List all credentials on the authenticator
464    List {
465        /// Filter by relying party ID (domain)
466        #[arg(short = 'd', long = "domain", value_name = "DOMAIN")]
467        rp_id: Option<String>,
468    },
469    /// Show detailed information about a specific credential
470    Show {
471        /// Credential ID in hexadecimal format
472        #[arg(value_name = "CREDENTIAL_ID")]
473        credential_id: String,
474    },
475    /// Delete a specific credential by ID
476    Delete {
477        /// Credential ID in hexadecimal format
478        #[arg(value_name = "CREDENTIAL_ID")]
479        credential_id: String,
480    },
481    /// Rename a credential (update user name and/or display name)
482    Rename {
483        /// Credential ID in hexadecimal format
484        #[arg(value_name = "CREDENTIAL_ID")]
485        credential_id: String,
486        /// New user name (login identifier)
487        #[arg(short = 'u', long = "user-name", value_name = "NAME")]
488        user_name: Option<String>,
489        /// New display name (friendly name)
490        #[arg(short = 'n', long = "display-name", value_name = "NAME")]
491        display_name: Option<String>,
492    },
493    /// PIN management commands
494    Pin {
495        #[command(subcommand)]
496        action: PinAction,
497    },
498}
499
500/// PIN management actions
501#[derive(Subcommand, Debug, Clone)]
502pub enum PinAction {
503    /// Set a new PIN (authenticator must not have a PIN set)
504    Set {
505        /// The new PIN (minimum 4 characters)
506        #[arg(value_name = "PIN")]
507        pin: String,
508    },
509    /// Change the existing PIN
510    Change {
511        /// The current PIN
512        #[arg(value_name = "OLD_PIN")]
513        old_pin: String,
514        /// The new PIN (minimum 4 characters)
515        #[arg(value_name = "NEW_PIN")]
516        new_pin: String,
517    },
518}