Skip to main content

mk_lib/
secrets.rs

1use std::env;
2use std::fs::{
3  self,
4  File,
5};
6use std::io::Write as _;
7use std::path::{
8  Path,
9  PathBuf,
10};
11use std::process::{
12  Command,
13  Stdio,
14};
15
16use anyhow::Context as _;
17use hashbrown::HashMap;
18use pgp::composed::{
19  Deserializable as _,
20  Message,
21  SignedSecretKey,
22};
23use serde::{
24  Deserialize,
25  Serialize,
26};
27
28use crate::file::ToUtf8 as _;
29use crate::utils::{
30  parse_env_contents,
31  resolve_path,
32};
33
34const VAULT_META_FILE: &str = ".vault-meta.toml";
35
36/// Metadata stored inside a vault directory that describes how the vault should be accessed.
37/// Written by `mk secrets vault init --gpg-key-id` so subsequent commands
38/// (store, show, export, …) pick up the GPG key automatically without flags.
39#[derive(Debug, Default, Deserialize, Serialize)]
40pub struct VaultMeta {
41  /// GPG key ID or fingerprint used to encrypt/decrypt secrets in this vault
42  #[serde(skip_serializing_if = "Option::is_none")]
43  pub gpg_key_id: Option<String>,
44}
45
46/// Read the GPG key ID stored in a vault's metadata file, if present.
47/// Returns `None` when the file does not exist or cannot be parsed.
48pub fn read_vault_gpg_key_id(vault_location: &Path) -> Option<String> {
49  let content = fs::read_to_string(vault_location.join(VAULT_META_FILE)).ok()?;
50  let meta: VaultMeta = toml::from_str(&content).ok()?;
51  meta.gpg_key_id
52}
53
54/// Write (or overwrite) the vault's metadata file with the supplied GPG key ID.
55pub fn write_vault_meta(vault_location: &Path, gpg_key_id: &str) -> anyhow::Result<()> {
56  let meta = VaultMeta {
57    gpg_key_id: Some(gpg_key_id.to_string()),
58  };
59  let content = toml::to_string_pretty(&meta).context("Failed to serialize vault metadata")?;
60  let meta_path = vault_location.join(VAULT_META_FILE);
61  let mut file = File::create(&meta_path)?;
62  file.write_all(content.as_bytes())?;
63  file.flush()?;
64  Ok(())
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct SecretConfig {
69  pub vault_location: PathBuf,
70  pub keys_location: PathBuf,
71  pub key_name: String,
72  pub gpg_key_id: Option<String>,
73}
74
75impl SecretConfig {
76  pub fn resolve(
77    base_dir: &Path,
78    vault_location: Option<&str>,
79    keys_location: Option<&str>,
80    key_name: Option<&str>,
81    gpg_key_id: Option<&str>,
82  ) -> Self {
83    let vault_location = vault_location
84      .map(|path| resolve_path(base_dir, path))
85      .unwrap_or_else(|| default_vault_location(base_dir));
86    let keys_location = keys_location
87      .map(|path| resolve_path(base_dir, path))
88      .unwrap_or_else(default_keys_location);
89    let key_name = key_name.unwrap_or("default").to_string();
90    // Resolve gpg_key_id: explicit argument > vault metadata file
91    let gpg_key_id = gpg_key_id
92      .map(|s| s.to_string())
93      .or_else(|| read_vault_gpg_key_id(&vault_location));
94
95    Self {
96      vault_location,
97      keys_location,
98      key_name,
99      gpg_key_id,
100    }
101  }
102}
103
104pub fn load_secret_values(
105  path: &str,
106  base_dir: &Path,
107  vault_location: Option<&str>,
108  keys_location: Option<&str>,
109  key_name: Option<&str>,
110  gpg_key_id: Option<&str>,
111) -> anyhow::Result<Vec<String>> {
112  let config = SecretConfig::resolve(base_dir, vault_location, keys_location, key_name, gpg_key_id);
113  verify_vault(&config.vault_location)?;
114
115  let secret_path = config.vault_location.join(path);
116  if !secret_path.exists() || !secret_path.is_dir() {
117    anyhow::bail!(
118      "Secret path does not exist: {}",
119      secret_path.to_utf8().unwrap_or("<non-utf8-path>")
120    );
121  }
122
123  let mut data_paths = fs::read_dir(&secret_path)?
124    .filter_map(Result::ok)
125    .map(|entry| {
126      if entry.path().is_dir() {
127        entry.path().join("data.asc")
128      } else {
129        entry.path()
130      }
131    })
132    .filter(|path| path.exists() && path.is_file())
133    .collect::<Vec<_>>();
134  data_paths.sort();
135
136  let use_gpg = config.gpg_key_id.is_some();
137  let signed_secret_key = if !use_gpg {
138    Some(load_secret_key(&config)?)
139  } else {
140    check_gpg_available()?;
141    None
142  };
143
144  let mut values = Vec::with_capacity(data_paths.len());
145  for data_path in data_paths {
146    let value = if use_gpg {
147      decrypt_with_gpg(&data_path)?
148    } else {
149      let key = signed_secret_key.as_ref().unwrap();
150      let mut data_file = std::io::BufReader::new(File::open(&data_path)?);
151      let (message, _) = Message::from_armor(&mut data_file)?;
152      let mut decrypted_message = message.decrypt(&pgp::types::Password::empty(), key)?;
153      decrypted_message
154        .as_data_string()
155        .context("Failed to read secret value")?
156    };
157    values.push(value);
158  }
159
160  if values.is_empty() {
161    anyhow::bail!("No secrets found for path: {path}");
162  }
163
164  Ok(values)
165}
166
167pub fn load_secret_value(
168  path: &str,
169  base_dir: &Path,
170  vault_location: Option<&str>,
171  keys_location: Option<&str>,
172  key_name: Option<&str>,
173  gpg_key_id: Option<&str>,
174) -> anyhow::Result<String> {
175  let values = load_secret_values(
176    path,
177    base_dir,
178    vault_location,
179    keys_location,
180    key_name,
181    gpg_key_id,
182  )?;
183  match values.as_slice() {
184    [value] => Ok(value.clone()),
185    [] => anyhow::bail!("No secrets found for path: {path}"),
186    _ => anyhow::bail!("Secret path resolved to multiple values: {path}"),
187  }
188}
189
190pub fn list_secret_paths(
191  path_prefix: Option<&str>,
192  base_dir: &Path,
193  vault_location: Option<&str>,
194) -> anyhow::Result<Vec<String>> {
195  let config = SecretConfig::resolve(base_dir, vault_location, None, None, None);
196  verify_vault(&config.vault_location)?;
197
198  let root = match path_prefix {
199    Some(path_prefix) if !path_prefix.is_empty() => config.vault_location.join(path_prefix),
200    _ => config.vault_location.clone(),
201  };
202
203  if !root.exists() || !root.is_dir() {
204    anyhow::bail!(
205      "Secret path does not exist: {}",
206      root.to_utf8().unwrap_or("<non-utf8-path>")
207    );
208  }
209
210  let mut secret_paths = Vec::new();
211  collect_secret_paths(&config.vault_location, &root, &mut secret_paths)?;
212  secret_paths.sort();
213  secret_paths.dedup();
214  Ok(secret_paths)
215}
216
217pub fn load_secret_env(
218  paths: &[String],
219  base_dir: &Path,
220  vault_location: Option<&str>,
221  keys_location: Option<&str>,
222  key_name: Option<&str>,
223  gpg_key_id: Option<&str>,
224) -> anyhow::Result<HashMap<String, String>> {
225  let mut env_vars = HashMap::new();
226
227  for path in paths {
228    for value in load_secret_values(
229      path,
230      base_dir,
231      vault_location,
232      keys_location,
233      key_name,
234      gpg_key_id,
235    )? {
236      env_vars.extend(parse_env_contents(&value));
237    }
238  }
239
240  Ok(env_vars)
241}
242
243/// Checks that the `gpg` binary is available in PATH, returning a clear error if not.
244/// Called early when any GPG-backend vault operation is attempted.
245fn check_gpg_available() -> anyhow::Result<()> {
246  which::which("gpg")
247    .context("gpg is not available in PATH — install GnuPG to use hardware key (YubiKey) support")?;
248  Ok(())
249}
250
251fn default_vault_location(base_dir: &Path) -> PathBuf {
252  resolve_path(base_dir, "./.mk/vault")
253}
254
255/// Encrypt `plaintext` using the system `gpg` binary for the given key ID or fingerprint.
256/// The output is ASCII-armored PGP data suitable for storing as a `data.asc` vault file.
257pub fn encrypt_with_gpg(gpg_key_id: &str, plaintext: &[u8]) -> anyhow::Result<Vec<u8>> {
258  check_gpg_available()?;
259  let mut child = Command::new("gpg")
260    .args([
261      "--batch",
262      "--yes",
263      "--armor",
264      "--encrypt",
265      "--recipient",
266      gpg_key_id,
267    ])
268    .stdin(Stdio::piped())
269    .stdout(Stdio::piped())
270    .stderr(Stdio::piped())
271    .spawn()
272    .context("Failed to spawn gpg — is it installed and in PATH?")?;
273
274  if let Some(mut stdin) = child.stdin.take() {
275    stdin
276      .write_all(plaintext)
277      .context("Failed to write plaintext to gpg stdin")?;
278  }
279
280  let output = child
281    .wait_with_output()
282    .context("Failed to wait for gpg encrypt")?;
283  if !output.status.success() {
284    let stderr = String::from_utf8_lossy(&output.stderr);
285    anyhow::bail!("gpg encryption failed: {}", stderr.trim());
286  }
287  Ok(output.stdout)
288}
289
290/// Decrypt a vault `data.asc` file using the system `gpg` binary.
291/// GPG-agent handles PIN/passphrase prompts automatically (including YubiKey via pinentry).
292fn decrypt_with_gpg(data_path: &Path) -> anyhow::Result<String> {
293  let path_str = data_path
294    .to_str()
295    .ok_or_else(|| anyhow::anyhow!("Non-UTF-8 path: {:?}", data_path))?;
296
297  let output = Command::new("gpg")
298    .args(["--batch", "--decrypt", path_str])
299    .stdout(Stdio::piped())
300    .stderr(Stdio::piped())
301    .spawn()
302    .context("Failed to spawn gpg — is it installed and in PATH?")?
303    .wait_with_output()
304    .context("Failed to wait for gpg decrypt")?;
305
306  if !output.status.success() {
307    let stderr = String::from_utf8_lossy(&output.stderr);
308    anyhow::bail!("gpg decryption failed: {}", stderr.trim());
309  }
310  String::from_utf8(output.stdout).context("gpg decrypt output is not valid UTF-8")
311}
312
313fn default_keys_location() -> PathBuf {
314  let home_dir = if cfg!(target_os = "windows") {
315    env::var("USERPROFILE").unwrap_or_else(|_| "./.mk/priv".to_string())
316  } else {
317    env::var("HOME").unwrap_or_else(|_| "./.mk/priv".to_string())
318  };
319
320  let mut path = PathBuf::from(home_dir);
321  path.push(".config");
322  path.push("mk");
323  path.push("priv");
324  path
325}
326
327fn verify_vault(vault_location: &Path) -> anyhow::Result<()> {
328  if !vault_location.exists() || !vault_location.is_dir() {
329    anyhow::bail!("The store does not exist");
330  }
331
332  Ok(())
333}
334
335fn load_secret_key(config: &SecretConfig) -> anyhow::Result<SignedSecretKey> {
336  if !config.keys_location.exists() || !config.keys_location.is_dir() {
337    anyhow::bail!("The keys location does not exist");
338  }
339
340  let key_path = config.keys_location.join(format!("{}.key", config.key_name));
341  if !key_path.exists() || !key_path.is_file() {
342    anyhow::bail!("The key does not exist");
343  }
344
345  let mut secret_key_string = File::open(key_path)?;
346  let (signed_secret_key, _) = SignedSecretKey::from_armor_single(&mut secret_key_string)?;
347  signed_secret_key.verify()?;
348  Ok(signed_secret_key)
349}
350
351fn collect_secret_paths(vault_root: &Path, dir: &Path, secret_paths: &mut Vec<String>) -> anyhow::Result<()> {
352  let data_path = dir.join("data.asc");
353  if data_path.exists() && data_path.is_file() {
354    let relative = dir
355      .strip_prefix(vault_root)
356      .map_err(|_| anyhow::anyhow!("Failed to resolve secret path relative to vault"))?;
357    secret_paths.push(relative.to_utf8().unwrap_or("<non-utf8-path>").to_string());
358  }
359
360  for entry in fs::read_dir(dir)?.filter_map(Result::ok) {
361    let path = entry.path();
362    if path.is_dir() {
363      collect_secret_paths(vault_root, &path, secret_paths)?;
364    }
365  }
366
367  Ok(())
368}
369
370#[cfg(test)]
371mod tests {
372  use std::fs;
373
374  use assert_fs::TempDir;
375
376  use super::*;
377
378  // ── VaultMeta / write_vault_meta / read_vault_gpg_key_id ──────────────────
379
380  #[test]
381  fn test_vault_meta_roundtrip() {
382    let dir = TempDir::new().unwrap();
383    let vault_dir = dir.path();
384
385    // Nothing written yet → returns None
386    assert_eq!(read_vault_gpg_key_id(vault_dir), None);
387
388    // Write a key ID
389    write_vault_meta(vault_dir, "ABC123DEF456").unwrap();
390
391    // Read it back
392    assert_eq!(read_vault_gpg_key_id(vault_dir), Some("ABC123DEF456".to_string()));
393  }
394
395  #[test]
396  fn test_vault_meta_overwrite() {
397    let dir = TempDir::new().unwrap();
398    let vault_dir = dir.path();
399
400    write_vault_meta(vault_dir, "FIRST_KEY").unwrap();
401    write_vault_meta(vault_dir, "SECOND_KEY").unwrap();
402
403    assert_eq!(read_vault_gpg_key_id(vault_dir), Some("SECOND_KEY".to_string()));
404  }
405
406  #[test]
407  fn test_read_vault_gpg_key_id_missing_file() {
408    let dir = TempDir::new().unwrap();
409    assert_eq!(read_vault_gpg_key_id(dir.path()), None);
410  }
411
412  #[test]
413  fn test_read_vault_gpg_key_id_invalid_toml() {
414    let dir = TempDir::new().unwrap();
415    fs::write(dir.path().join(VAULT_META_FILE), b"not_valid [ toml {{").unwrap();
416    // Should return None gracefully, no panic
417    assert_eq!(read_vault_gpg_key_id(dir.path()), None);
418  }
419
420  // ── SecretConfig::resolve ─────────────────────────────────────────────────
421
422  #[test]
423  fn test_secret_config_explicit_gpg_key_id() {
424    let dir = TempDir::new().unwrap();
425    let vault_dir = dir.path().to_str().unwrap();
426    let base = Path::new(".");
427    let config = SecretConfig::resolve(base, Some(vault_dir), None, None, Some("EXPLICIT_ID"));
428    assert_eq!(config.gpg_key_id, Some("EXPLICIT_ID".to_string()));
429  }
430
431  #[test]
432  fn test_secret_config_gpg_key_id_from_vault_metadata() {
433    let dir = TempDir::new().unwrap();
434    let vault_dir = dir.path().to_str().unwrap();
435    write_vault_meta(dir.path(), "META_ID").unwrap();
436
437    let base = Path::new(".");
438    let config = SecretConfig::resolve(base, Some(vault_dir), None, None, None);
439    assert_eq!(config.gpg_key_id, Some("META_ID".to_string()));
440  }
441
442  #[test]
443  fn test_secret_config_explicit_gpg_key_id_overrides_metadata() {
444    let dir = TempDir::new().unwrap();
445    let vault_dir = dir.path().to_str().unwrap();
446    write_vault_meta(dir.path(), "META_ID").unwrap();
447
448    let base = Path::new(".");
449    let config = SecretConfig::resolve(base, Some(vault_dir), None, None, Some("EXPLICIT_ID"));
450    // Explicit arg wins over metadata
451    assert_eq!(config.gpg_key_id, Some("EXPLICIT_ID".to_string()));
452  }
453
454  #[test]
455  fn test_secret_config_no_gpg_key_id() {
456    let dir = TempDir::new().unwrap();
457    let vault_dir = dir.path().to_str().unwrap();
458    let base = Path::new(".");
459    // Empty vault dir — no .vault-meta.toml written
460    let config = SecretConfig::resolve(base, Some(vault_dir), None, None, None);
461    assert_eq!(config.gpg_key_id, None);
462  }
463
464  #[test]
465  fn test_secret_config_key_name_default() {
466    let dir = TempDir::new().unwrap();
467    let vault_dir = dir.path().to_str().unwrap();
468    let base = Path::new(".");
469    let config = SecretConfig::resolve(base, Some(vault_dir), None, None, None);
470    assert_eq!(config.key_name, "default");
471  }
472
473  #[test]
474  fn test_secret_config_key_name_custom() {
475    let dir = TempDir::new().unwrap();
476    let vault_dir = dir.path().to_str().unwrap();
477    let base = Path::new(".");
478    let config = SecretConfig::resolve(base, Some(vault_dir), None, Some("mykey"), None);
479    assert_eq!(config.key_name, "mykey");
480  }
481
482  // ── VaultMeta serialization ───────────────────────────────────────────────
483
484  #[test]
485  fn test_vault_meta_toml_no_gpg_key_id() {
486    // When gpg_key_id is None, the field is skipped in the TOML output
487    let meta = VaultMeta { gpg_key_id: None };
488    let s = toml::to_string_pretty(&meta).unwrap();
489    assert!(!s.contains("gpg_key_id"), "unexpected field in: {s}");
490  }
491
492  #[test]
493  fn test_vault_meta_toml_with_gpg_key_id() {
494    let meta = VaultMeta {
495      gpg_key_id: Some("FINGERPRINT".to_string()),
496    };
497    let s = toml::to_string_pretty(&meta).unwrap();
498    assert!(s.contains("gpg_key_id"), "field missing from: {s}");
499    assert!(s.contains("FINGERPRINT"));
500  }
501}