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 '{}' not found in vault. List available secrets with: mk secrets vault list",
119      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!(
162      "No secrets found for path '{}'. List available secrets with: mk secrets vault list",
163      path
164    );
165  }
166
167  Ok(values)
168}
169
170pub fn load_secret_value(
171  path: &str,
172  base_dir: &Path,
173  vault_location: Option<&str>,
174  keys_location: Option<&str>,
175  key_name: Option<&str>,
176  gpg_key_id: Option<&str>,
177) -> anyhow::Result<String> {
178  let values = load_secret_values(
179    path,
180    base_dir,
181    vault_location,
182    keys_location,
183    key_name,
184    gpg_key_id,
185  )?;
186  match values.as_slice() {
187    [value] => Ok(value.clone()),
188    [] => anyhow::bail!(
189      "No secrets found for path '{}'. List available secrets with: mk secrets vault list",
190      path
191    ),
192    _ => anyhow::bail!(
193      "Secret path '{}' resolved to multiple values; use a more specific identifier",
194      path
195    ),
196  }
197}
198
199pub fn list_secret_paths(
200  path_prefix: Option<&str>,
201  base_dir: &Path,
202  vault_location: Option<&str>,
203) -> anyhow::Result<Vec<String>> {
204  let config = SecretConfig::resolve(base_dir, vault_location, None, None, None);
205  verify_vault(&config.vault_location)?;
206
207  let root = match path_prefix {
208    Some(path_prefix) if !path_prefix.is_empty() => config.vault_location.join(path_prefix),
209    _ => config.vault_location.clone(),
210  };
211
212  if !root.exists() || !root.is_dir() {
213    anyhow::bail!(
214      "Secret prefix '{}' not found in vault. List available secrets with: mk secrets vault list",
215      path_prefix.unwrap_or("<unknown>")
216    );
217  }
218
219  let mut secret_paths = Vec::new();
220  collect_secret_paths(&config.vault_location, &root, &mut secret_paths)?;
221  secret_paths.sort();
222  secret_paths.dedup();
223  Ok(secret_paths)
224}
225
226pub fn load_secret_env(
227  paths: &[String],
228  base_dir: &Path,
229  vault_location: Option<&str>,
230  keys_location: Option<&str>,
231  key_name: Option<&str>,
232  gpg_key_id: Option<&str>,
233) -> anyhow::Result<HashMap<String, String>> {
234  let mut env_vars = HashMap::new();
235
236  for path in paths {
237    for value in load_secret_values(
238      path,
239      base_dir,
240      vault_location,
241      keys_location,
242      key_name,
243      gpg_key_id,
244    )? {
245      env_vars.extend(parse_env_contents(&value));
246    }
247  }
248
249  Ok(env_vars)
250}
251
252/// Checks that the `gpg` binary is available in PATH, returning a clear error if not.
253/// Called early when any GPG-backend vault operation is attempted.
254fn check_gpg_available() -> anyhow::Result<()> {
255  which::which("gpg")
256    .context("gpg is not available in PATH — install GnuPG to use hardware key (YubiKey) support")?;
257  Ok(())
258}
259
260fn default_vault_location(base_dir: &Path) -> PathBuf {
261  resolve_path(base_dir, "./.mk/vault")
262}
263
264/// Encrypt `plaintext` using the system `gpg` binary for the given key ID or fingerprint.
265/// The output is ASCII-armored PGP data suitable for storing as a `data.asc` vault file.
266pub fn encrypt_with_gpg(gpg_key_id: &str, plaintext: &[u8]) -> anyhow::Result<Vec<u8>> {
267  check_gpg_available()?;
268  let mut child = Command::new("gpg")
269    .args([
270      "--batch",
271      "--yes",
272      "--armor",
273      "--encrypt",
274      "--recipient",
275      gpg_key_id,
276    ])
277    .stdin(Stdio::piped())
278    .stdout(Stdio::piped())
279    .stderr(Stdio::piped())
280    .spawn()
281    .context("Failed to spawn gpg — is it installed and in PATH?")?;
282
283  if let Some(mut stdin) = child.stdin.take() {
284    stdin
285      .write_all(plaintext)
286      .context("Failed to write plaintext to gpg stdin")?;
287  }
288
289  let output = child
290    .wait_with_output()
291    .context("Failed to wait for gpg encrypt")?;
292  if !output.status.success() {
293    let stderr = String::from_utf8_lossy(&output.stderr);
294    anyhow::bail!("gpg encryption failed: {}", stderr.trim());
295  }
296  Ok(output.stdout)
297}
298
299/// Decrypt a vault `data.asc` file using the system `gpg` binary.
300/// GPG-agent handles PIN/passphrase prompts automatically (including YubiKey via pinentry).
301fn decrypt_with_gpg(data_path: &Path) -> anyhow::Result<String> {
302  let path_str = data_path
303    .to_str()
304    .ok_or_else(|| anyhow::anyhow!("Non-UTF-8 path: {:?}", data_path))?;
305
306  let output = Command::new("gpg")
307    .args(["--batch", "--decrypt", path_str])
308    .stdout(Stdio::piped())
309    .stderr(Stdio::piped())
310    .spawn()
311    .context("Failed to spawn gpg — is it installed and in PATH?")?
312    .wait_with_output()
313    .context("Failed to wait for gpg decrypt")?;
314
315  if !output.status.success() {
316    let stderr = String::from_utf8_lossy(&output.stderr);
317    anyhow::bail!("gpg decryption failed: {}", stderr.trim());
318  }
319  String::from_utf8(output.stdout).context("gpg decrypt output is not valid UTF-8")
320}
321
322fn default_keys_location() -> PathBuf {
323  let home_dir = if cfg!(target_os = "windows") {
324    env::var("USERPROFILE").unwrap_or_else(|_| "./.mk/priv".to_string())
325  } else {
326    env::var("HOME").unwrap_or_else(|_| "./.mk/priv".to_string())
327  };
328
329  let mut path = PathBuf::from(home_dir);
330  path.push(".config");
331  path.push("mk");
332  path.push("priv");
333  path
334}
335
336fn verify_vault(vault_location: &Path) -> anyhow::Result<()> {
337  if !vault_location.exists() || !vault_location.is_dir() {
338    anyhow::bail!(
339      "Vault not found at '{}'. Initialize it first with: mk secrets vault init",
340      vault_location.to_utf8().unwrap_or("<non-utf8-path>")
341    );
342  }
343
344  Ok(())
345}
346
347fn load_secret_key(config: &SecretConfig) -> anyhow::Result<SignedSecretKey> {
348  if !config.keys_location.exists() || !config.keys_location.is_dir() {
349    anyhow::bail!(
350      "Keys directory not found at '{}'. Generate a key first with: mk secrets key gen",
351      config.keys_location.to_utf8().unwrap_or("<non-utf8-path>")
352    );
353  }
354
355  let key_path = config.keys_location.join(format!("{}.key", config.key_name));
356  if !key_path.exists() || !key_path.is_file() {
357    anyhow::bail!(
358      "Key '{}' not found in '{}'. Generate it with: mk secrets key gen --name {}",
359      config.key_name,
360      config.keys_location.to_utf8().unwrap_or("<non-utf8-path>"),
361      config.key_name
362    );
363  }
364
365  let mut secret_key_string = File::open(key_path)?;
366  let (signed_secret_key, _) = SignedSecretKey::from_armor_single(&mut secret_key_string)?;
367  signed_secret_key.verify_bindings()?;
368  Ok(signed_secret_key)
369}
370
371fn collect_secret_paths(vault_root: &Path, dir: &Path, secret_paths: &mut Vec<String>) -> anyhow::Result<()> {
372  let data_path = dir.join("data.asc");
373  if data_path.exists() && data_path.is_file() {
374    let relative = dir.strip_prefix(vault_root).map_err(|_| {
375      let dir = dir.to_utf8().unwrap_or("<non-utf8-path>");
376      let vault_root = vault_root.to_utf8().unwrap_or("<non-utf8-path>");
377      anyhow::anyhow!(
378        "Failed to resolve secret path '{}' relative to vault root '{}'",
379        dir,
380        vault_root
381      )
382    })?;
383    secret_paths.push(relative.to_utf8().unwrap_or("<non-utf8-path>").to_string());
384  }
385
386  for entry in fs::read_dir(dir)?.filter_map(Result::ok) {
387    let path = entry.path();
388    if path.is_dir() {
389      collect_secret_paths(vault_root, &path, secret_paths)?;
390    }
391  }
392
393  Ok(())
394}
395
396#[cfg(test)]
397mod tests {
398  use std::fs;
399
400  use assert_fs::TempDir;
401
402  use super::*;
403
404  // ── VaultMeta / write_vault_meta / read_vault_gpg_key_id ──────────────────
405
406  #[test]
407  fn test_vault_meta_roundtrip() {
408    let dir = TempDir::new().unwrap();
409    let vault_dir = dir.path();
410
411    // Nothing written yet → returns None
412    assert_eq!(read_vault_gpg_key_id(vault_dir), None);
413
414    // Write a key ID
415    write_vault_meta(vault_dir, "ABC123DEF456").unwrap();
416
417    // Read it back
418    assert_eq!(read_vault_gpg_key_id(vault_dir), Some("ABC123DEF456".to_string()));
419  }
420
421  #[test]
422  fn test_vault_meta_overwrite() {
423    let dir = TempDir::new().unwrap();
424    let vault_dir = dir.path();
425
426    write_vault_meta(vault_dir, "FIRST_KEY").unwrap();
427    write_vault_meta(vault_dir, "SECOND_KEY").unwrap();
428
429    assert_eq!(read_vault_gpg_key_id(vault_dir), Some("SECOND_KEY".to_string()));
430  }
431
432  #[test]
433  fn test_read_vault_gpg_key_id_missing_file() {
434    let dir = TempDir::new().unwrap();
435    assert_eq!(read_vault_gpg_key_id(dir.path()), None);
436  }
437
438  #[test]
439  fn test_read_vault_gpg_key_id_invalid_toml() {
440    let dir = TempDir::new().unwrap();
441    fs::write(dir.path().join(VAULT_META_FILE), b"not_valid [ toml {{").unwrap();
442    // Should return None gracefully, no panic
443    assert_eq!(read_vault_gpg_key_id(dir.path()), None);
444  }
445
446  // ── SecretConfig::resolve ─────────────────────────────────────────────────
447
448  #[test]
449  fn test_secret_config_explicit_gpg_key_id() {
450    let dir = TempDir::new().unwrap();
451    let vault_dir = dir.path().to_str().unwrap();
452    let base = Path::new(".");
453    let config = SecretConfig::resolve(base, Some(vault_dir), None, None, Some("EXPLICIT_ID"));
454    assert_eq!(config.gpg_key_id, Some("EXPLICIT_ID".to_string()));
455  }
456
457  #[test]
458  fn test_secret_config_gpg_key_id_from_vault_metadata() {
459    let dir = TempDir::new().unwrap();
460    let vault_dir = dir.path().to_str().unwrap();
461    write_vault_meta(dir.path(), "META_ID").unwrap();
462
463    let base = Path::new(".");
464    let config = SecretConfig::resolve(base, Some(vault_dir), None, None, None);
465    assert_eq!(config.gpg_key_id, Some("META_ID".to_string()));
466  }
467
468  #[test]
469  fn test_secret_config_explicit_gpg_key_id_overrides_metadata() {
470    let dir = TempDir::new().unwrap();
471    let vault_dir = dir.path().to_str().unwrap();
472    write_vault_meta(dir.path(), "META_ID").unwrap();
473
474    let base = Path::new(".");
475    let config = SecretConfig::resolve(base, Some(vault_dir), None, None, Some("EXPLICIT_ID"));
476    // Explicit arg wins over metadata
477    assert_eq!(config.gpg_key_id, Some("EXPLICIT_ID".to_string()));
478  }
479
480  #[test]
481  fn test_secret_config_no_gpg_key_id() {
482    let dir = TempDir::new().unwrap();
483    let vault_dir = dir.path().to_str().unwrap();
484    let base = Path::new(".");
485    // Empty vault dir — no .vault-meta.toml written
486    let config = SecretConfig::resolve(base, Some(vault_dir), None, None, None);
487    assert_eq!(config.gpg_key_id, None);
488  }
489
490  #[test]
491  fn test_secret_config_key_name_default() {
492    let dir = TempDir::new().unwrap();
493    let vault_dir = dir.path().to_str().unwrap();
494    let base = Path::new(".");
495    let config = SecretConfig::resolve(base, Some(vault_dir), None, None, None);
496    assert_eq!(config.key_name, "default");
497  }
498
499  #[test]
500  fn test_secret_config_key_name_custom() {
501    let dir = TempDir::new().unwrap();
502    let vault_dir = dir.path().to_str().unwrap();
503    let base = Path::new(".");
504    let config = SecretConfig::resolve(base, Some(vault_dir), None, Some("mykey"), None);
505    assert_eq!(config.key_name, "mykey");
506  }
507
508  // ── VaultMeta serialization ───────────────────────────────────────────────
509
510  #[test]
511  fn test_vault_meta_toml_no_gpg_key_id() {
512    // When gpg_key_id is None, the field is skipped in the TOML output
513    let meta = VaultMeta { gpg_key_id: None };
514    let s = toml::to_string_pretty(&meta).unwrap();
515    assert!(!s.contains("gpg_key_id"), "unexpected field in: {s}");
516  }
517
518  #[test]
519  fn test_vault_meta_toml_with_gpg_key_id() {
520    let meta = VaultMeta {
521      gpg_key_id: Some("FINGERPRINT".to_string()),
522    };
523    let s = toml::to_string_pretty(&meta).unwrap();
524    assert!(s.contains("gpg_key_id"), "field missing from: {s}");
525    assert!(s.contains("FINGERPRINT"));
526  }
527}