Skip to main content

murk_cli/
init.rs

1//! Vault initialization logic.
2
3use std::collections::{BTreeMap, HashMap};
4use std::process::Command;
5// Only the tests touch the process environment directly; the runtime key read
6// now goes through env::key_from_env_only (see tests/invariants.rs).
7#[cfg(test)]
8use std::env;
9
10use crate::{crypto, encrypt_value, now_utc, types};
11use age::secrecy::{ExposeSecret, SecretString};
12
13/// Strip embedded credentials from a git remote URL.
14///
15/// Handles `https://user:pass@host/repo` → `https://host/repo` and
16/// `https://token@host/repo` → `https://host/repo`.
17/// SSH and other formats are returned as-is (no credentials to strip).
18fn sanitize_remote_url(url: &str) -> String {
19    if let Some(rest) = url
20        .strip_prefix("https://")
21        .or_else(|| url.strip_prefix("http://"))
22    {
23        let scheme = if url.starts_with("https://") {
24            "https"
25        } else {
26            "http"
27        };
28        if let Some(at_pos) = rest.find('@') {
29            // Only strip if the '@' is before the first '/' (i.e. in the authority).
30            let slash_pos = rest.find('/').unwrap_or(rest.len());
31            if at_pos < slash_pos {
32                return format!("{scheme}://{}", &rest[at_pos + 1..]);
33            }
34        }
35        url.to_string()
36    } else {
37        url.to_string()
38    }
39}
40
41/// A key discovered from the environment or .env file.
42#[derive(Debug)]
43pub struct DiscoveredKey {
44    pub secret_key: SecretString,
45    pub pubkey: String,
46}
47
48/// Try to find an existing age key from the environment.
49///
50/// Checks `MURK_KEY` first, then reads the file at `MURK_KEY_FILE` if set.
51/// Does NOT read `.env` — for direnv users, the shim already exports both
52/// variables into the environment, so the environment is the authoritative
53/// source and `.env` is only a write-only convenience populated by `murk init`.
54pub fn discover_existing_key() -> Result<Option<DiscoveredKey>, String> {
55    // The env vars are read in one place (the env module) so the auth read path
56    // stays auditable — see tests/invariants.rs.
57    let raw = crate::env::key_from_env_only()?;
58
59    match raw {
60        Some(key) => {
61            let identity =
62                crypto::parse_identity(key.expose_secret()).map_err(|e| e.to_string())?;
63            let pubkey = identity.pubkey_string().map_err(|e| e.to_string())?;
64            Ok(Some(DiscoveredKey {
65                secret_key: key,
66                pubkey,
67            }))
68        }
69        None => Ok(None),
70    }
71}
72
73/// Status of an existing vault relative to a given key.
74#[derive(Debug)]
75pub struct InitStatus {
76    /// Whether the key's pubkey is in the vault's recipient list.
77    pub authorized: bool,
78    /// The public key derived from the secret key.
79    pub pubkey: String,
80    /// Display name from encrypted meta, if decryptable and present.
81    pub display_name: Option<String>,
82}
83
84/// Check whether a secret key is authorized in an existing vault.
85///
86/// Parses the identity from `secret_key`, checks the recipient list, and
87/// attempts to decrypt meta for the display name.
88pub fn check_init_status(vault: &types::Vault, secret_key: &str) -> Result<InitStatus, String> {
89    let identity = crypto::parse_identity(secret_key).map_err(|e| e.to_string())?;
90    let pubkey = identity.pubkey_string().map_err(|e| e.to_string())?;
91    let authorized = vault.recipients.contains(&pubkey);
92
93    let display_name = if authorized {
94        crate::decrypt_meta(vault, &identity)
95            .and_then(|meta| meta.recipients.get(&pubkey).cloned())
96            .filter(|name| !name.is_empty())
97    } else {
98        None
99    };
100
101    Ok(InitStatus {
102        authorized,
103        pubkey,
104        display_name,
105    })
106}
107
108/// Create a new vault with a single recipient.
109///
110/// Detects the git remote URL and builds the initial vault struct.
111/// The caller is responsible for writing the vault to disk via `vault::write`.
112pub fn create_vault(
113    vault_name: &str,
114    pubkey: &str,
115    name: &str,
116) -> Result<types::Vault, crate::error::MurkError> {
117    use crate::error::MurkError;
118
119    let mut recipient_names = HashMap::new();
120    recipient_names.insert(pubkey.to_string(), name.to_string());
121
122    let recipient = crypto::parse_recipient(pubkey)?;
123
124    // Detect git repo URL, stripping any embedded credentials.
125    let repo = Command::new("git")
126        .args(["remote", "get-url", "origin"])
127        .output()
128        .ok()
129        .filter(|o| o.status.success())
130        .and_then(|o| String::from_utf8(o.stdout).ok())
131        .map(|s| sanitize_remote_url(s.trim()))
132        .unwrap_or_default();
133
134    let mut vault = types::Vault {
135        version: types::VAULT_VERSION.into(),
136        created: now_utc(),
137        vault_name: vault_name.into(),
138        repo,
139        recipients: vec![pubkey.to_string()],
140        schema: BTreeMap::new(),
141        policy: None,
142        secrets: BTreeMap::new(),
143        meta: String::new(),
144    };
145
146    let mac_key_hex = crate::generate_mac_key();
147    let mac_key = crate::decode_mac_key(&mac_key_hex).unwrap();
148    let mac = crate::compute_mac(&vault, &BTreeMap::new(), &BTreeMap::new(), Some(&mac_key));
149    // The initial vault is unsigned: `create_vault` holds only the public key,
150    // and an empty vault has nothing to protect. The first secret write signs it.
151    let meta = types::Meta {
152        recipients: recipient_names,
153        mac,
154        mac_key: Some(mac_key_hex),
155        github_pins: HashMap::new(),
156        groups: BTreeMap::new(),
157        grants: BTreeMap::new(),
158        signers: BTreeMap::new(),
159        sig: None,
160    };
161    let meta_json =
162        serde_json::to_vec(&meta).map_err(|e| MurkError::Secret(format!("meta serialize: {e}")))?;
163    vault.meta = encrypt_value(&meta_json, &[recipient])?;
164
165    Ok(vault)
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::testutil::*;
172    use crate::testutil::{CWD_LOCK, ENV_LOCK};
173
174    // ── discover_existing_key tests ──
175
176    #[test]
177    fn discover_existing_key_from_env() {
178        let _lock = ENV_LOCK
179            .lock()
180            .unwrap_or_else(std::sync::PoisonError::into_inner);
181        let (secret, pubkey) = generate_keypair();
182        unsafe { env::set_var("MURK_KEY", &secret) };
183        let result = discover_existing_key();
184        unsafe { env::remove_var("MURK_KEY") };
185
186        let dk = result.unwrap().unwrap();
187        assert_eq!(dk.secret_key.expose_secret(), secret.as_str());
188        assert_eq!(dk.pubkey, pubkey);
189    }
190
191    #[test]
192    fn discover_existing_key_ignores_dotenv() {
193        // discover_existing_key must not read .env from CWD, even
194        // in the init flow. A .env sitting in the current directory with an
195        // inline MURK_KEY is explicitly *not* a trusted input source.
196        let _lock = ENV_LOCK
197            .lock()
198            .unwrap_or_else(std::sync::PoisonError::into_inner);
199        let _cwd = CWD_LOCK
200            .lock()
201            .unwrap_or_else(std::sync::PoisonError::into_inner);
202        unsafe {
203            env::remove_var("MURK_KEY");
204            env::remove_var("MURK_KEY_FILE");
205        }
206
207        let dir = std::env::temp_dir().join("murk_test_discover_ignores_dotenv");
208        std::fs::create_dir_all(&dir).unwrap();
209        let (secret, _pubkey) = generate_keypair();
210        std::fs::write(dir.join(".env"), format!("MURK_KEY={secret}\n")).unwrap();
211
212        let orig_dir = std::env::current_dir().unwrap();
213        std::env::set_current_dir(&dir).unwrap();
214        let result = discover_existing_key();
215        std::env::set_current_dir(&orig_dir).unwrap();
216        std::fs::remove_dir_all(&dir).unwrap();
217
218        assert!(
219            result.unwrap().is_none(),
220            "discover_existing_key must not fall back to .env"
221        );
222    }
223
224    #[test]
225    fn discover_existing_key_from_env_file_var() {
226        let _lock = ENV_LOCK
227            .lock()
228            .unwrap_or_else(std::sync::PoisonError::into_inner);
229        unsafe {
230            env::remove_var("MURK_KEY");
231        }
232
233        let (secret, pubkey) = generate_keypair();
234        let dir = std::env::temp_dir().join("murk_test_discover_env_file");
235        std::fs::create_dir_all(&dir).unwrap();
236        let key_path = dir.join("key");
237        std::fs::write(&key_path, format!("{secret}\n")).unwrap();
238        #[cfg(unix)]
239        {
240            use std::os::unix::fs::PermissionsExt;
241            std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
242        }
243
244        unsafe { env::set_var("MURK_KEY_FILE", &key_path) };
245        let result = discover_existing_key();
246        unsafe { env::remove_var("MURK_KEY_FILE") };
247        std::fs::remove_dir_all(&dir).unwrap();
248
249        let dk = result.unwrap().unwrap();
250        assert_eq!(dk.secret_key.expose_secret(), secret.as_str());
251        assert_eq!(dk.pubkey, pubkey);
252    }
253
254    #[test]
255    fn discover_existing_key_neither_set() {
256        let _lock = ENV_LOCK
257            .lock()
258            .unwrap_or_else(std::sync::PoisonError::into_inner);
259        let _cwd = CWD_LOCK
260            .lock()
261            .unwrap_or_else(std::sync::PoisonError::into_inner);
262        unsafe { env::remove_var("MURK_KEY") };
263
264        // Use a dir with no .env.
265        let dir = std::env::temp_dir().join("murk_test_discover_none");
266        std::fs::create_dir_all(&dir).unwrap();
267        let orig_dir = std::env::current_dir().unwrap();
268        std::env::set_current_dir(&dir).unwrap();
269        let result = discover_existing_key();
270        std::env::set_current_dir(&orig_dir).unwrap();
271        std::fs::remove_dir_all(&dir).unwrap();
272
273        assert!(result.unwrap().is_none());
274    }
275
276    #[test]
277    fn discover_existing_key_invalid_key() {
278        let _lock = ENV_LOCK
279            .lock()
280            .unwrap_or_else(std::sync::PoisonError::into_inner);
281        unsafe { env::set_var("MURK_KEY", "not-a-valid-age-key") };
282        let result = discover_existing_key();
283        unsafe { env::remove_var("MURK_KEY") };
284
285        assert!(result.is_err());
286    }
287
288    // ── check_init_status tests ──
289
290    #[test]
291    fn check_init_status_authorized() {
292        let (secret, pubkey) = generate_keypair();
293        let recipient = make_recipient(&pubkey);
294
295        // Build a vault with this recipient in the list and encrypted meta.
296        let mut names = HashMap::new();
297        names.insert(pubkey.clone(), "Alice".to_string());
298        let meta = types::Meta {
299            recipients: names,
300            mac: String::new(),
301            mac_key: None,
302            github_pins: HashMap::new(),
303            ..Default::default()
304        };
305        let meta_json = serde_json::to_vec(&meta).unwrap();
306        let meta_enc = encrypt_value(&meta_json, &[recipient]).unwrap();
307
308        let vault = types::Vault {
309            version: "2.0".into(),
310            created: "2026-01-01T00:00:00Z".into(),
311            vault_name: ".murk".into(),
312            repo: String::new(),
313            recipients: vec![pubkey.clone()],
314            schema: std::collections::BTreeMap::new(),
315            policy: None,
316            secrets: std::collections::BTreeMap::new(),
317            meta: meta_enc,
318        };
319
320        let status = check_init_status(&vault, &secret).unwrap();
321        assert!(status.authorized);
322        assert_eq!(status.pubkey, pubkey);
323        assert_eq!(status.display_name.as_deref(), Some("Alice"));
324    }
325
326    #[test]
327    fn check_init_status_not_authorized() {
328        let (secret, pubkey) = generate_keypair();
329        let (_, other_pubkey) = generate_keypair();
330
331        let vault = types::Vault {
332            version: "2.0".into(),
333            created: "2026-01-01T00:00:00Z".into(),
334            vault_name: ".murk".into(),
335            repo: String::new(),
336            recipients: vec![other_pubkey],
337            schema: std::collections::BTreeMap::new(),
338            policy: None,
339            secrets: std::collections::BTreeMap::new(),
340            meta: String::new(),
341        };
342
343        let status = check_init_status(&vault, &secret).unwrap();
344        assert!(!status.authorized);
345        assert_eq!(status.pubkey, pubkey);
346        assert!(status.display_name.is_none());
347    }
348
349    #[test]
350    fn create_vault_basic() {
351        let (_, pubkey) = generate_keypair();
352
353        let vault = create_vault(".murk", &pubkey, "Bob").unwrap();
354        assert_eq!(vault.version, types::VAULT_VERSION);
355        assert_eq!(vault.vault_name, ".murk");
356        assert_eq!(vault.recipients, vec![pubkey]);
357        assert!(vault.schema.is_empty());
358        assert!(vault.secrets.is_empty());
359        assert!(!vault.meta.is_empty());
360    }
361
362    // ── sanitize_remote_url tests ──
363
364    #[test]
365    fn sanitize_strips_https_credentials() {
366        assert_eq!(
367            sanitize_remote_url("https://user:pass@github.com/org/repo.git"),
368            "https://github.com/org/repo.git"
369        );
370    }
371
372    #[test]
373    fn sanitize_strips_https_token() {
374        assert_eq!(
375            sanitize_remote_url("https://ghp_abc123@github.com/org/repo.git"),
376            "https://github.com/org/repo.git"
377        );
378    }
379
380    #[test]
381    fn sanitize_preserves_clean_https() {
382        assert_eq!(
383            sanitize_remote_url("https://github.com/org/repo.git"),
384            "https://github.com/org/repo.git"
385        );
386    }
387
388    #[test]
389    fn sanitize_preserves_ssh() {
390        assert_eq!(
391            sanitize_remote_url("git@github.com:org/repo.git"),
392            "git@github.com:org/repo.git"
393        );
394    }
395
396    #[test]
397    fn sanitize_strips_http_credentials() {
398        assert_eq!(
399            sanitize_remote_url("http://user:pass@example.com/repo"),
400            "http://example.com/repo"
401        );
402    }
403}