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#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
102#[group(id = "tpm-backend-config")]
103pub struct TpmBackendConfig {
104    /// Path to TPM storage directory
105    #[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    /// TPM TCTI (TPM Command Transmission Interface) configuration
116    #[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/// Security configuration
123#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
124#[group(id = "security")]
125pub struct SecurityConfig {
126    /// Check if mlock is available to prevent credentials from being swapped to disk
127    #[arg(long = "check-mlock", env = "PASSLESS_CHECK_MLOCK")]
128    #[serde(default)]
129    #[default(true)]
130    pub check_mlock: bool,
131
132    /// Disable core dumps to prevent credential leakage
133    #[arg(long = "disable-core-dumps", env = "PASSLESS_DISABLE_CORE_DUMPS")]
134    #[serde(default)]
135    #[default(true)]
136    pub disable_core_dumps: bool,
137
138    /// Enable constant signature counter to help RPs detect cloned authenticators
139    #[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    /// Show user verification notification during registration
152    #[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    /// Show user verification notification during authentication
161    #[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    /// Notification timeout in seconds (0 = no timeout)
170    #[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    /// Apply security hardening measures
182    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    /// Disable core dumps to prevent credential leakage
193    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    /// Probe mlock capability by testing with a small allocation
204    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/// Main application configuration
227/// Note: Cannot derive Clone/Debug because it has #[clap_serde] fields
228#[derive(ClapSerde, Serialize, Deserialize, Debug, ConfigDoc)]
229pub struct AppConfig {
230    /// Storage backend type: pass, tpm (experimental), or local (for testing)
231    #[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    /// Enable verbose logging
237    // workaround for allowing `-v` syntax instead of `-v=true`
238    #[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    /// Pass backend configuration
252    #[clap_serde]
253    #[serde(default)]
254    #[command(flatten)]
255    pub pass: PassBackendConfig,
256
257    /// TPM backend configuration
258    #[clap_serde]
259    #[serde(default)]
260    #[command(flatten)]
261    pub tpm: TpmBackendConfig,
262
263    /// Local backend configuration
264    #[clap_serde]
265    #[serde(default)]
266    #[command(flatten)]
267    pub local: LocalBackendConfig,
268
269    /// Security hardening configuration
270    #[clap_serde]
271    #[serde(default)]
272    #[command(flatten)]
273    pub security: SecurityConfig,
274}
275
276/// Backend-specific configuration
277#[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    /// Load configuration with precedence: CLI > config file > defaults
295    pub fn load(args: &mut Args) -> Self {
296        // Try to load config file
297        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                    // Merge: file config + CLI args (CLI takes precedence)
313                    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        // No config file or parse failed - use CLI args + defaults
320        AppConfig::from(&mut args.config)
321    }
322
323    /// Get the backend configuration based on the backend_type
324    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    /// Apply security hardening measures
346    pub fn apply_security_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
347        self.security.apply_hardening()
348    }
349
350    /// Get security configuration
351    pub fn security_config(&self) -> SecurityConfig {
352        self.security.clone()
353    }
354}
355
356/// CLI arguments structure
357#[derive(Parser)]
358#[command(author, version, about)]
359pub struct Args {
360    /// Path to configuration file (TOML format)
361    #[arg(short, long, env = "PASSLESS_CONFIG")]
362    pub config_path: Option<PathBuf>,
363
364    /// Application configuration (can come from CLI or config file)
365    #[command(flatten)]
366    pub config: <AppConfig as ClapSerde>::Opt,
367
368    /// Subcommands
369    #[command(subcommand)]
370    pub command: Option<Commands>,
371}
372
373/// Output format for client commands
374#[derive(Debug, Clone, Copy, PartialEq, Eq)]
375pub enum OutputFormat {
376    /// Human-readable plain text output
377    Plain,
378    /// JSON output for programmatic consumption
379    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/// Subcommands for passless
407#[derive(Subcommand, Debug, Clone)]
408pub enum Commands {
409    /// Configuration management commands
410    Config {
411        #[command(subcommand)]
412        action: ConfigAction,
413    },
414    /// FIDO2 client commands for managing authenticators
415    ///
416    /// These commands require a running authenticator. For testing:
417    /// 1. Start authenticator: PASSLESS_E2E_AUTO_ACCEPT_UV=1 cargo run -- --backend-type local
418    /// 2. Run client commands in another terminal with the same environment variable
419    Client {
420        /// Select device by index (0-based) or name. Use 'devices' subcommand to list available devices.
421        #[arg(short = 'D', long = "device", value_name = "INDEX|NAME", global = true)]
422        device: Option<String>,
423
424        /// Output format: plain (default) or json
425        #[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/// Configuration actions
440#[derive(Subcommand, Debug, Clone)]
441pub enum ConfigAction {
442    /// Print the default configuration in TOML format
443    Print,
444}
445
446/// Client actions for FIDO2 authenticator management
447#[derive(Subcommand, Debug, Clone)]
448pub enum ClientAction {
449    /// List all available FIDO2 authenticators/devices
450    Devices,
451    /// Get authenticator information (capabilities, AAGUID, versions, etc.)
452    Info,
453    /// Reset the authenticator (WARNING: deletes ALL credentials)
454    Reset {
455        /// Confirmation flag that must be provided twice for safety
456        #[arg(long = "yes-i-really-want-to-reset-my-device", action = ArgAction::Count)]
457        confirm: u8,
458    },
459    /// List all credentials on the authenticator
460    List {
461        /// Filter by relying party ID (domain)
462        #[arg(short = 'd', long = "domain", value_name = "DOMAIN")]
463        rp_id: Option<String>,
464    },
465    /// Show detailed information about a specific credential
466    Show {
467        /// Credential ID in hexadecimal format
468        #[arg(value_name = "CREDENTIAL_ID")]
469        credential_id: String,
470    },
471    /// Delete a specific credential by ID
472    Delete {
473        /// Credential ID in hexadecimal format
474        #[arg(value_name = "CREDENTIAL_ID")]
475        credential_id: String,
476    },
477    /// Rename a credential (update user name and/or display name)
478    Rename {
479        /// Credential ID in hexadecimal format
480        #[arg(value_name = "CREDENTIAL_ID")]
481        credential_id: String,
482        /// New user name (login identifier)
483        #[arg(short = 'u', long = "user-name", value_name = "NAME")]
484        user_name: Option<String>,
485        /// New display name (friendly name)
486        #[arg(short = 'n', long = "display-name", value_name = "NAME")]
487        display_name: Option<String>,
488    },
489    /// PIN management commands
490    Pin {
491        #[command(subcommand)]
492        action: PinAction,
493    },
494}
495
496/// PIN management actions
497#[derive(Subcommand, Debug, Clone)]
498pub enum PinAction {
499    /// Set a new PIN (authenticator must not have a PIN set)
500    Set {
501        /// The new PIN (minimum 4 characters)
502        #[arg(value_name = "PIN")]
503        pin: String,
504    },
505    /// Change the existing PIN
506    Change {
507        /// The current PIN
508        #[arg(value_name = "OLD_PIN")]
509        old_pin: String,
510        /// The new PIN (minimum 4 characters)
511        #[arg(value_name = "NEW_PIN")]
512        new_pin: String,
513    },
514}