1use 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
21pub 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#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
32#[group(id = "local-backend-config")]
33pub struct LocalBackendConfig {
34 #[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
46pub 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#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
56#[group(id = "pass-backend-config")]
57pub struct PassBackendConfig {
58 #[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 #[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 #[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
91pub 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#[cfg(feature = "tpm")]
102#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
103#[group(id = "tpm-backend-config")]
104pub struct TpmBackendConfig {
105 #[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 #[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#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
125#[group(id = "security")]
126pub struct SecurityConfig {
127 #[arg(long = "check-mlock", env = "PASSLESS_CHECK_MLOCK")]
129 #[serde(default)]
130 #[default(true)]
131 pub check_mlock: bool,
132
133 #[arg(long = "disable-core-dumps", env = "PASSLESS_DISABLE_CORE_DUMPS")]
135 #[serde(default)]
136 #[default(true)]
137 pub disable_core_dumps: bool,
138
139 #[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 #[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 #[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 #[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 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 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 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#[derive(ClapSerde, Serialize, Deserialize, Debug, ConfigDoc)]
230pub struct AppConfig {
231 #[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 #[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 #[clap_serde]
254 #[serde(default)]
255 #[command(flatten)]
256 pub pass: PassBackendConfig,
257
258 #[cfg(feature = "tpm")]
260 #[clap_serde]
261 #[serde(default)]
262 #[command(flatten)]
263 pub tpm: TpmBackendConfig,
264
265 #[clap_serde]
267 #[serde(default)]
268 #[command(flatten)]
269 pub local: LocalBackendConfig,
270
271 #[clap_serde]
273 #[serde(default)]
274 #[command(flatten)]
275 pub security: SecurityConfig,
276}
277
278#[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 pub fn load(args: &mut Args) -> Self {
299 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 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 AppConfig::from(&mut args.config)
324 }
325
326 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 pub fn apply_security_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
351 self.security.apply_hardening()
352 }
353
354 pub fn security_config(&self) -> SecurityConfig {
356 self.security.clone()
357 }
358}
359
360#[derive(Parser)]
362#[command(author, version, about)]
363pub struct Args {
364 #[arg(short, long, env = "PASSLESS_CONFIG")]
366 pub config_path: Option<PathBuf>,
367
368 #[command(flatten)]
370 pub config: <AppConfig as ClapSerde>::Opt,
371
372 #[command(subcommand)]
374 pub command: Option<Commands>,
375}
376
377#[derive(Debug, Clone, Copy, PartialEq, Eq)]
379pub enum OutputFormat {
380 Plain,
382 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#[derive(Subcommand, Debug, Clone)]
412pub enum Commands {
413 Config {
415 #[command(subcommand)]
416 action: ConfigAction,
417 },
418 Client {
424 #[arg(short = 'D', long = "device", value_name = "INDEX|NAME", global = true)]
426 device: Option<String>,
427
428 #[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#[derive(Subcommand, Debug, Clone)]
445pub enum ConfigAction {
446 Print,
448}
449
450#[derive(Subcommand, Debug, Clone)]
452pub enum ClientAction {
453 Devices,
455 Info,
457 Reset {
459 #[arg(long = "yes-i-really-want-to-reset-my-device", action = ArgAction::Count)]
461 confirm: u8,
462 },
463 List {
465 #[arg(short = 'd', long = "domain", value_name = "DOMAIN")]
467 rp_id: Option<String>,
468 },
469 Show {
471 #[arg(value_name = "CREDENTIAL_ID")]
473 credential_id: String,
474 },
475 Delete {
477 #[arg(value_name = "CREDENTIAL_ID")]
479 credential_id: String,
480 },
481 Rename {
483 #[arg(value_name = "CREDENTIAL_ID")]
485 credential_id: String,
486 #[arg(short = 'u', long = "user-name", value_name = "NAME")]
488 user_name: Option<String>,
489 #[arg(short = 'n', long = "display-name", value_name = "NAME")]
491 display_name: Option<String>,
492 },
493 Pin {
495 #[command(subcommand)]
496 action: PinAction,
497 },
498}
499
500#[derive(Subcommand, Debug, Clone)]
502pub enum PinAction {
503 Set {
505 #[arg(value_name = "PIN")]
507 pin: String,
508 },
509 Change {
511 #[arg(value_name = "OLD_PIN")]
513 old_pin: String,
514 #[arg(value_name = "NEW_PIN")]
516 new_pin: String,
517 },
518}