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
170impl SecurityConfig {
171    /// Apply security hardening measures
172    pub fn apply_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
173        if self.disable_core_dumps {
174            self.disable_core_dumps_impl()?;
175        }
176        if self.check_mlock {
177            self.probe_mlock_capability()?;
178        }
179        Ok(())
180    }
181
182    /// Disable core dumps to prevent credential leakage
183    fn disable_core_dumps_impl(&self) -> Result<(), Box<dyn std::error::Error>> {
184        debug!("Disabling core dumps to prevent credential leakage");
185        setrlimit(Resource::RLIMIT_CORE, 0, 0)?;
186        let r = unsafe { prctl(PR_SET_DUMPABLE, 0, 0, 0, 0) };
187        if r != 0 {
188            log::warn!("prctl(PR_SET_DUMPABLE) failed: {}", r);
189        }
190        Ok(())
191    }
192
193    /// Probe mlock capability by testing with a small allocation
194    fn probe_mlock_capability(&self) -> Result<(), Box<dyn std::error::Error>> {
195        debug!("Check mlock capability");
196
197        let test_size = 4096;
198        let test_buffer = vec![0u8; test_size];
199        let ptr = test_buffer.as_ptr() as *const libc::c_void;
200
201        let lock_result = unsafe { mlock(ptr, test_size) };
202
203        if lock_result == 0 {
204            unsafe { munlock(ptr, test_size) };
205            log::debug!("MLOCK is enabled - sensitive data will not be swapped to disk");
206        } else {
207            log::warn!(
208                "mlock capability probe failed - memory locking may not be available.\n\
209                 Hint: grant CAP_IPC_LOCK to the binary with: 'sudo setcap cap_ipc_lock=+ep $(which passless)'"
210            );
211        }
212        Ok(())
213    }
214}
215
216/// Main application configuration
217/// Note: Cannot derive Clone/Debug because it has #[clap_serde] fields
218#[derive(ClapSerde, Serialize, Deserialize, Debug, ConfigDoc)]
219pub struct AppConfig {
220    /// Storage backend type: local, pass, or tpm
221    #[arg(short = 't', long = "backend-type", env = "PASSLESS_BACKEND_TYPE")]
222    #[serde(default)]
223    #[default("local".to_string())]
224    pub backend_type: String,
225
226    /// Enable verbose logging
227    // workaround for allowing `-v` syntax instead of `-v=true`
228    #[arg(
229        short,
230        long,
231        env = "PASSLESS_VERBOSE",
232        action = ArgAction::Set,
233        require_equals = true,
234        num_args = 0..=1,
235        default_missing_value = "true"
236    )]
237    #[default(true)]
238    #[serde(default)]
239    pub verbose: bool,
240
241    /// Local backend configuration
242    #[clap_serde]
243    #[serde(default)]
244    #[command(flatten)]
245    pub local: LocalBackendConfig,
246
247    /// Pass backend configuration
248    #[clap_serde]
249    #[serde(default)]
250    #[command(flatten)]
251    pub pass: PassBackendConfig,
252
253    /// TPM backend configuration
254    #[clap_serde]
255    #[serde(default)]
256    #[command(flatten)]
257    pub tpm: TpmBackendConfig,
258
259    /// Security hardening configuration
260    #[clap_serde]
261    #[serde(default)]
262    #[command(flatten)]
263    pub security: SecurityConfig,
264}
265
266/// Backend-specific configuration
267#[derive(Debug, Clone)]
268pub enum BackendConfig {
269    Local {
270        path: String,
271    },
272    Pass {
273        store_path: String,
274        path: String,
275        gpg_backend: String,
276    },
277    Tpm {
278        path: String,
279        tcti: String,
280    },
281}
282
283impl AppConfig {
284    /// Load configuration with precedence: CLI > config file > defaults
285    pub fn load(args: &mut Args) -> Self {
286        // Try to load config file
287        let default_config_path = dirs::config_dir().map(|p| p.join("passless/config.toml"));
288
289        let config_file_path = args
290            .config_path
291            .as_ref()
292            .or(default_config_path.as_ref())
293            .filter(|p| p.exists());
294
295        if let Some(path) = config_file_path
296            && let Ok(f) = File::open(path)
297        {
298            log::info!("Loading configuration from: {}", path.display());
299            let content = std::io::read_to_string(BufReader::new(f)).unwrap_or_default();
300            match toml::from_str::<AppConfig>(&content) {
301                Ok(file_config) => {
302                    // Merge: file config + CLI args (CLI takes precedence)
303                    return file_config.merge(&mut args.config);
304                }
305                Err(e) => log::warn!("Failed to parse config file {}: {}", path.display(), e),
306            }
307        }
308
309        // No config file or parse failed - use CLI args + defaults
310        AppConfig::from(&mut args.config)
311    }
312
313    /// Get the backend configuration based on the backend_type
314    pub fn backend(&self) -> crate::error::Result<BackendConfig> {
315        match self.backend_type.as_str() {
316            "local" => Ok(BackendConfig::Local {
317                path: self.local.path.clone(),
318            }),
319            "pass" => Ok(BackendConfig::Pass {
320                store_path: self.pass.store_path.clone(),
321                path: self.pass.path.clone(),
322                gpg_backend: self.pass.gpg_backend.clone(),
323            }),
324            "tpm" => Ok(BackendConfig::Tpm {
325                path: self.tpm.path.clone(),
326                tcti: self.tpm.tcti.clone(),
327            }),
328            _ => Err(crate::error::Error::Config(format!(
329                "Invalid backend_type '{}'. Must be one of: local, pass, tpm",
330                self.backend_type
331            ))),
332        }
333    }
334
335    /// Apply security hardening measures
336    pub fn apply_security_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
337        self.security.apply_hardening()
338    }
339
340    /// Get security configuration
341    pub fn security_config(&self) -> SecurityConfig {
342        self.security.clone()
343    }
344}
345
346/// CLI arguments structure
347#[derive(Parser)]
348#[command(author, version, about)]
349pub struct Args {
350    /// Path to configuration file (TOML format)
351    #[arg(short, long, env = "PASSLESS_CONFIG")]
352    pub config_path: Option<PathBuf>,
353
354    /// Application configuration (can come from CLI or config file)
355    #[command(flatten)]
356    pub config: <AppConfig as ClapSerde>::Opt,
357
358    /// Subcommands
359    #[command(subcommand)]
360    pub command: Option<Commands>,
361}
362
363/// Subcommands for passless
364#[derive(Subcommand, Debug, Clone)]
365pub enum Commands {
366    /// Configuration management commands
367    Config {
368        #[command(subcommand)]
369        action: ConfigAction,
370    },
371}
372
373/// Configuration actions
374#[derive(Subcommand, Debug, Clone)]
375pub enum ConfigAction {
376    /// Print the default configuration in TOML format
377    Print,
378}