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#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
102#[group(id = "tpm-backend-config")]
103pub struct TpmBackendConfig {
104 #[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 #[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#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
124#[group(id = "security")]
125pub struct SecurityConfig {
126 #[arg(long = "check-mlock", env = "PASSLESS_CHECK_MLOCK")]
128 #[serde(default)]
129 #[default(true)]
130 pub check_mlock: bool,
131
132 #[arg(long = "disable-core-dumps", env = "PASSLESS_DISABLE_CORE_DUMPS")]
134 #[serde(default)]
135 #[default(true)]
136 pub disable_core_dumps: bool,
137
138 #[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 #[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 #[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 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 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 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#[derive(ClapSerde, Serialize, Deserialize, Debug, ConfigDoc)]
219pub struct AppConfig {
220 #[arg(short = 't', long = "backend-type", env = "PASSLESS_BACKEND_TYPE")]
222 #[serde(default)]
223 #[default("pass".to_string())]
224 pub backend_type: String,
225
226 #[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 #[clap_serde]
243 #[serde(default)]
244 #[command(flatten)]
245 pub pass: PassBackendConfig,
246
247 #[clap_serde]
249 #[serde(default)]
250 #[command(flatten)]
251 pub tpm: TpmBackendConfig,
252
253 #[clap_serde]
255 #[serde(default)]
256 #[command(flatten)]
257 pub local: LocalBackendConfig,
258
259 #[clap_serde]
261 #[serde(default)]
262 #[command(flatten)]
263 pub security: SecurityConfig,
264}
265
266#[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 pub fn load(args: &mut Args) -> Self {
286 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 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 AppConfig::from(&mut args.config)
311 }
312
313 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 pub fn apply_security_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
337 self.security.apply_hardening()
338 }
339
340 pub fn security_config(&self) -> SecurityConfig {
342 self.security.clone()
343 }
344}
345
346#[derive(Parser)]
348#[command(author, version, about)]
349pub struct Args {
350 #[arg(short, long, env = "PASSLESS_CONFIG")]
352 pub config_path: Option<PathBuf>,
353
354 #[command(flatten)]
356 pub config: <AppConfig as ClapSerde>::Opt,
357
358 #[command(subcommand)]
360 pub command: Option<Commands>,
361}
362
363#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365pub enum OutputFormat {
366 Plain,
368 Json,
370}
371
372impl std::str::FromStr for OutputFormat {
373 type Err = String;
374
375 fn from_str(s: &str) -> Result<Self, Self::Err> {
376 match s.to_lowercase().as_str() {
377 "plain" => Ok(OutputFormat::Plain),
378 "json" => Ok(OutputFormat::Json),
379 _ => Err(format!(
380 "Invalid output format '{}'. Must be 'plain' or 'json'",
381 s
382 )),
383 }
384 }
385}
386
387impl std::fmt::Display for OutputFormat {
388 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
389 match self {
390 OutputFormat::Plain => write!(f, "plain"),
391 OutputFormat::Json => write!(f, "json"),
392 }
393 }
394}
395
396#[derive(Subcommand, Debug, Clone)]
398pub enum Commands {
399 Config {
401 #[command(subcommand)]
402 action: ConfigAction,
403 },
404 Client {
410 #[arg(short = 'D', long = "device", value_name = "INDEX|NAME", global = true)]
412 device: Option<String>,
413
414 #[arg(
416 short = 'o',
417 long = "output",
418 value_name = "FORMAT",
419 default_value = "plain",
420 global = true
421 )]
422 output: OutputFormat,
423
424 #[command(subcommand)]
425 action: ClientAction,
426 },
427}
428
429#[derive(Subcommand, Debug, Clone)]
431pub enum ConfigAction {
432 Print,
434}
435
436#[derive(Subcommand, Debug, Clone)]
438pub enum ClientAction {
439 Devices,
441 Info,
443 Reset {
445 #[arg(long = "yes-i-really-want-to-reset-my-device", action = ArgAction::Count)]
447 confirm: u8,
448 },
449 List {
451 #[arg(short = 'd', long = "domain", value_name = "DOMAIN")]
453 rp_id: Option<String>,
454 },
455 Show {
457 #[arg(value_name = "CREDENTIAL_ID")]
459 credential_id: String,
460 },
461 Delete {
463 #[arg(value_name = "CREDENTIAL_ID")]
465 credential_id: String,
466 },
467 Rename {
469 #[arg(value_name = "CREDENTIAL_ID")]
471 credential_id: String,
472 #[arg(short = 'u', long = "user-name", value_name = "NAME")]
474 user_name: Option<String>,
475 #[arg(short = 'n', long = "display-name", value_name = "NAME")]
477 display_name: Option<String>,
478 },
479 Pin {
481 #[command(subcommand)]
482 action: PinAction,
483 },
484}
485
486#[derive(Subcommand, Debug, Clone)]
488pub enum PinAction {
489 Set {
491 #[arg(value_name = "PIN")]
493 pin: String,
494 },
495 Change {
497 #[arg(value_name = "OLD_PIN")]
499 old_pin: String,
500 #[arg(value_name = "NEW_PIN")]
502 new_pin: String,
503 },
504}