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::{MCL_CURRENT, MCL_FUTURE, PR_SET_DUMPABLE, mlockall, prctl};
15use log::debug;
16use nix::sys::resource::{Resource, setrlimit};
17use passless_config_doc::ConfigDoc;
18use serde::{Deserialize, Serialize};
19
20/// Compute default local storage path
21pub fn local_path() -> String {
22    dirs::data_dir()
23        .expect("Could not determine data directory: $XDG_DATA_HOME or $HOME/.local/share")
24        .join("passless/local")
25        .to_string_lossy()
26        .into_owned()
27}
28
29/// Local backend configuration
30#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
31#[group(id = "local-backend-config")]
32pub struct LocalBackendConfig {
33    /// Path to local storage directory
34    #[arg(
35        long = "local-path",
36        env = "PASSLESS_LOCAL_PATH",
37        id = "local-path",
38        value_name = "PATH"
39    )]
40    #[serde(default)]
41    #[default(local_path())]
42    pub path: String,
43}
44
45/// Compute default password-store path
46pub fn pass_store_path() -> String {
47    dirs::home_dir()
48        .expect("Could not determine home directory: $HOME")
49        .join(".password-store")
50        .to_string_lossy()
51        .into_owned()
52}
53/// Pass (password-store) backend configuration
54#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
55#[group(id = "pass-backend-config")]
56pub struct PassBackendConfig {
57    /// Path to password store directory
58    #[arg(
59        long = "pass-store-path",
60        env = "PASSLESS_PASS_STORE_PATH",
61        id = "pass-store-path",
62        value_name = "PATH"
63    )]
64    #[serde(default)]
65    #[default(pass_store_path())]
66    pub store_path: String,
67
68    /// Relative path within password store for FIDO2 entries
69    #[arg(
70        long = "pass-path",
71        env = "PASSLESS_PASS_PATH",
72        id = "pass-path",
73        value_name = "PATH"
74    )]
75    #[serde(default)]
76    #[default("fido2".to_string())]
77    pub path: String,
78
79    /// GPG backend: "gpgme" or "gnupg-bin"
80    #[arg(
81        long = "pass-gpg-backend",
82        env = "PASSLESS_PASS_GPG_BACKEND",
83        value_name = "BACKEND"
84    )]
85    #[serde(default)]
86    #[default("gnupg-bin".to_string())]
87    pub gpg_backend: String,
88}
89
90/// Compute default TPM storage path
91pub fn tpm_path() -> String {
92    dirs::data_dir()
93        .expect("Could not determine data directory: $XDG_DATA_HOME or $HOME/.local/share")
94        .join("passless/tpm")
95        .to_string_lossy()
96        .into_owned()
97}
98
99/// TPM backend configuration
100#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
101#[group(id = "tpm-backend-config")]
102pub struct TpmBackendConfig {
103    /// Path to TPM storage directory
104    #[arg(
105        long = "tpm-path",
106        env = "PASSLESS_TPM_PATH",
107        id = "tpm-path",
108        value_name = "PATH"
109    )]
110    #[serde(default)]
111    #[default(tpm_path())]
112    pub path: String,
113
114    /// TPM TCTI (TPM Command Transmission Interface) configuration
115    #[arg(long = "tpm-tcti", env = "PASSLESS_TPM_TCTI", value_name = "TCTI")]
116    #[serde(default)]
117    #[default("device:/dev/tpmrm0".to_string())]
118    pub tcti: String,
119}
120
121/// Security configuration
122#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
123#[group(id = "security")]
124pub struct SecurityConfig {
125    /// Use mlock to prevent credentials from being swapped to disk (requires CAP_IPC_LOCK)
126    #[arg(long = "use-mlock", env = "PASSLESS_USE_MLOCK")]
127    #[serde(default)]
128    #[default(true)]
129    pub use_mlock: bool,
130
131    /// Disable core dumps to prevent credential leakage
132    #[arg(long = "disable-core-dumps", env = "PASSLESS_DISABLE_CORE_DUMPS")]
133    #[serde(default)]
134    #[default(true)]
135    pub disable_core_dumps: bool,
136
137    /// Enable constant signature counter to help RPs detect cloned authenticators
138    #[arg(
139        long = "constant-signature-counter",
140        env = "PASSLESS_CONSTANT_SIGNATURE_COUNTER",
141        action = ArgAction::Set,
142        require_equals = true,
143        num_args = 0..=1,
144        default_missing_value = "true"
145    )]
146    #[serde(default)]
147    #[default(false)]
148    pub constant_signature_counter: bool,
149
150    /// Show user verification notification during registration
151    #[arg(
152        long = "user-verification-registration",
153        env = "PASSLESS_USER_VERIFICATION_REGISTRATION"
154    )]
155    #[serde(default)]
156    #[default(true)]
157    pub user_verification_registration: bool,
158
159    /// Show user verification notification during authentication
160    #[arg(
161        long = "user-verification-authentication",
162        env = "PASSLESS_USER_VERIFICATION_AUTHENTICATION"
163    )]
164    #[serde(default)]
165    #[default(true)]
166    pub user_verification_authentication: bool,
167}
168
169impl SecurityConfig {
170    /// Apply security hardening measures
171    pub fn apply_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
172        if self.disable_core_dumps {
173            self.disable_core_dumps_impl()?;
174        }
175        if self.use_mlock {
176            self.lock_all_memory()?;
177        }
178        Ok(())
179    }
180
181    /// Disable core dumps to prevent credential leakage
182    fn disable_core_dumps_impl(&self) -> Result<(), Box<dyn std::error::Error>> {
183        debug!("Disabling core dumps to prevent credential leakage");
184        setrlimit(Resource::RLIMIT_CORE, 0, 0)?;
185        let r = unsafe { prctl(PR_SET_DUMPABLE, 0, 0, 0, 0) };
186        if r != 0 {
187            log::warn!("prctl(PR_SET_DUMPABLE) failed: {}", r);
188        }
189        Ok(())
190    }
191
192    /// Lock all current and future memory mappings to prevent swapping
193    fn lock_all_memory(&self) -> Result<(), Box<dyn std::error::Error>> {
194        debug!("Locking all memory to prevent swapping");
195        let r = unsafe { mlockall(MCL_CURRENT | MCL_FUTURE) };
196        if r != 0 {
197            return Err(format!(
198                "mlockall failed (errno {}).\n\
199                 Hint: increase DefaultLimitMEMLOCK at /etc/systemd/system.conf and /etc/systemd/user.conf level.\n\
200                 Hint: grant CAP_IPC_LOCK to the binary with: 'sudo setcap cap_ipc_lock=+ep $(which passless)'",
201                std::io::Error::last_os_error()
202            )
203            .into());
204        }
205        Ok(())
206    }
207}
208
209/// Main application configuration
210/// Note: Cannot derive Clone/Debug because it has #[clap_serde] fields
211#[derive(ClapSerde, Serialize, Deserialize, Debug, ConfigDoc)]
212pub struct AppConfig {
213    /// Storage backend type: local, pass, or tpm
214    #[arg(short = 't', long = "backend-type", env = "PASSLESS_BACKEND_TYPE")]
215    #[serde(default)]
216    #[default("local".to_string())]
217    pub backend_type: String,
218
219    /// Enable verbose logging
220    // workaround for allowing `-v` syntax instead of `-v=true`
221    #[arg(
222        short,
223        long,
224        env = "PASSLESS_VERBOSE",
225        action = ArgAction::Set,
226        require_equals = true,
227        num_args = 0..=1,
228        default_missing_value = "true"
229    )]
230    #[default(true)]
231    #[serde(default)]
232    pub verbose: bool,
233
234    /// Local backend configuration
235    #[clap_serde]
236    #[serde(default)]
237    #[command(flatten)]
238    pub local: LocalBackendConfig,
239
240    /// Pass backend configuration
241    #[clap_serde]
242    #[serde(default)]
243    #[command(flatten)]
244    pub pass: PassBackendConfig,
245
246    /// TPM backend configuration
247    #[clap_serde]
248    #[serde(default)]
249    #[command(flatten)]
250    pub tpm: TpmBackendConfig,
251
252    /// Security hardening configuration
253    #[clap_serde]
254    #[serde(default)]
255    #[command(flatten)]
256    pub security: SecurityConfig,
257}
258
259/// Backend-specific configuration
260#[derive(Debug, Clone)]
261pub enum BackendConfig {
262    Local {
263        path: String,
264    },
265    Pass {
266        store_path: String,
267        path: String,
268        gpg_backend: String,
269    },
270    Tpm {
271        path: String,
272        tcti: String,
273    },
274}
275
276impl AppConfig {
277    /// Load configuration with precedence: CLI > config file > defaults
278    pub fn load(args: &mut Args) -> Self {
279        // Try to load config file
280        let default_config_path = dirs::config_dir().map(|p| p.join("passless/config.toml"));
281
282        let config_file_path = args
283            .config_path
284            .as_ref()
285            .or(default_config_path.as_ref())
286            .filter(|p| p.exists());
287
288        if let Some(path) = config_file_path
289            && let Ok(f) = File::open(path)
290        {
291            log::info!("Loading configuration from: {}", path.display());
292            let content = std::io::read_to_string(BufReader::new(f)).unwrap_or_default();
293            match toml::from_str::<AppConfig>(&content) {
294                Ok(file_config) => {
295                    // Merge: file config + CLI args (CLI takes precedence)
296                    return file_config.merge(&mut args.config);
297                }
298                Err(e) => log::warn!("Failed to parse config file {}: {}", path.display(), e),
299            }
300        }
301
302        // No config file or parse failed - use CLI args + defaults
303        AppConfig::from(&mut args.config)
304    }
305
306    /// Get the backend configuration based on the backend_type
307    pub fn backend(&self) -> crate::error::Result<BackendConfig> {
308        match self.backend_type.as_str() {
309            "local" => Ok(BackendConfig::Local {
310                path: self.local.path.clone(),
311            }),
312            "pass" => Ok(BackendConfig::Pass {
313                store_path: self.pass.store_path.clone(),
314                path: self.pass.path.clone(),
315                gpg_backend: self.pass.gpg_backend.clone(),
316            }),
317            "tpm" => Ok(BackendConfig::Tpm {
318                path: self.tpm.path.clone(),
319                tcti: self.tpm.tcti.clone(),
320            }),
321            _ => Err(crate::error::Error::Config(format!(
322                "Invalid backend_type '{}'. Must be one of: local, pass, tpm",
323                self.backend_type
324            ))),
325        }
326    }
327
328    /// Apply security hardening measures
329    pub fn apply_security_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
330        self.security.apply_hardening()
331    }
332
333    /// Get security configuration
334    pub fn security_config(&self) -> SecurityConfig {
335        self.security.clone()
336    }
337}
338
339/// CLI arguments structure
340#[derive(Parser)]
341#[command(author, version, about)]
342pub struct Args {
343    /// Path to configuration file (TOML format)
344    #[arg(short, long, env = "PASSLESS_CONFIG")]
345    pub config_path: Option<PathBuf>,
346
347    /// Application configuration (can come from CLI or config file)
348    #[command(flatten)]
349    pub config: <AppConfig as ClapSerde>::Opt,
350
351    /// Subcommands
352    #[command(subcommand)]
353    pub command: Option<Commands>,
354}
355
356/// Subcommands for passless
357#[derive(Subcommand, Debug, Clone)]
358pub enum Commands {
359    /// Configuration management commands
360    Config {
361        #[command(subcommand)]
362        action: ConfigAction,
363    },
364}
365
366/// Configuration actions
367#[derive(Subcommand, Debug, Clone)]
368pub enum ConfigAction {
369    /// Print the default configuration in TOML format
370    Print,
371}