Skip to main content

murk_cli/
recipients.rs

1//! Recipient management: authorize, revoke, and list vault recipients.
2
3use crate::{crypto, types};
4
5/// A single recipient entry with resolved display info.
6#[derive(Debug)]
7pub struct RecipientEntry {
8    pub pubkey: String,
9    pub display_name: Option<String>,
10    pub is_self: bool,
11}
12
13/// List all recipients in the vault with optional name resolution.
14///
15/// If `secret_key` is provided, decrypts meta to resolve display names
16/// and marks which recipient corresponds to the caller's key.
17pub fn list_recipients(vault: &types::Vault, secret_key: Option<&str>) -> Vec<RecipientEntry> {
18    let meta_data = secret_key.filter(|k| !k.is_empty()).and_then(|sk| {
19        let identity = crypto::parse_identity(sk).ok()?;
20        let my_pubkey = identity.pubkey_string().ok()?;
21        let meta = crate::decrypt_meta(vault, &identity)?;
22        Some((meta, my_pubkey))
23    });
24
25    vault
26        .recipients
27        .iter()
28        .map(|pk| {
29            let (display_name, is_self) = match &meta_data {
30                Some((meta, my_pubkey)) => {
31                    let name = meta.recipients.get(pk).filter(|n| !n.is_empty()).cloned();
32                    (name, pk == my_pubkey)
33                }
34                None => (None, false),
35            };
36            RecipientEntry {
37                pubkey: pk.clone(),
38                display_name,
39                is_self,
40            }
41        })
42        .collect()
43}
44
45/// Add a recipient to the vault. Returns an error if the pubkey is invalid or already present.
46pub fn authorize_recipient(
47    vault: &mut types::Vault,
48    murk: &mut types::Murk,
49    pubkey: &str,
50    name: Option<&str>,
51) -> Result<(), String> {
52    if crypto::parse_recipient(pubkey).is_err() {
53        return Err(format!("invalid public key: {pubkey}"));
54    }
55
56    if vault.recipients.contains(&pubkey.to_string()) {
57        return Err(format!("{pubkey} is already a recipient"));
58    }
59
60    vault.recipients.push(pubkey.into());
61
62    if let Some(n) = name {
63        murk.recipients.insert(pubkey.into(), n.into());
64    }
65
66    Ok(())
67}
68
69/// Result of revoking a recipient.
70#[derive(Debug)]
71pub struct RevokeResult {
72    /// The display name of the revoked recipient, if known.
73    pub display_name: Option<String>,
74    /// Keys the revoked recipient had access to (for rotation warnings).
75    pub exposed_keys: Vec<String>,
76}
77
78/// Remove a recipient from the vault. `recipient` can be a pubkey or a display name.
79///
80/// When matched by display name, removes **all** recipients sharing that name
81/// (e.g. multiple SSH keys added via `github:username`).
82/// Returns an error if the recipient is not found or would remove the last recipient.
83pub fn revoke_recipient(
84    vault: &mut types::Vault,
85    murk: &mut types::Murk,
86    recipient: &str,
87) -> Result<RevokeResult, String> {
88    // Resolve to one or more pubkeys.
89    let pubkeys: Vec<String> = if vault.recipients.contains(&recipient.to_string()) {
90        // Exact pubkey match — single key.
91        vec![recipient.to_string()]
92    } else {
93        // Name match — collect ALL pubkeys with this display name.
94        let matched: Vec<String> = murk
95            .recipients
96            .iter()
97            .filter(|(_, name)| name.as_str() == recipient)
98            .map(|(pk, _)| pk.clone())
99            .collect();
100        if matched.is_empty() {
101            return Err(format!("recipient not found: {recipient}"));
102        }
103        matched
104    };
105
106    if vault.recipients.len() <= pubkeys.len() {
107        return Err(
108            "cannot revoke last recipient — vault would become permanently inaccessible".into(),
109        );
110    }
111
112    let mut display_name = None;
113    for pubkey in &pubkeys {
114        vault.recipients.retain(|pk| pk != pubkey);
115
116        if let Some(name) = murk.recipients.remove(pubkey) {
117            display_name = Some(name);
118        }
119
120        // Remove their scoped entries.
121        for scoped_map in murk.scoped.values_mut() {
122            scoped_map.remove(pubkey);
123        }
124        for entry in vault.secrets.values_mut() {
125            entry.scoped.remove(pubkey);
126        }
127    }
128
129    let exposed_keys = vault.schema.keys().cloned().collect();
130
131    Ok(RevokeResult {
132        display_name,
133        exposed_keys,
134    })
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::testutil::*;
141    use crate::types;
142    use std::collections::{BTreeMap, HashMap};
143
144    #[test]
145    fn authorize_recipient_success() {
146        let (_, pubkey) = generate_keypair();
147        let mut vault = empty_vault();
148        let mut murk = empty_murk();
149
150        let result = authorize_recipient(&mut vault, &mut murk, &pubkey, Some("alice"));
151        assert!(result.is_ok());
152        assert!(vault.recipients.contains(&pubkey));
153        assert_eq!(murk.recipients[&pubkey], "alice");
154    }
155
156    #[test]
157    fn authorize_recipient_no_name() {
158        let (_, pubkey) = generate_keypair();
159        let mut vault = empty_vault();
160        let mut murk = empty_murk();
161
162        authorize_recipient(&mut vault, &mut murk, &pubkey, None).unwrap();
163        assert!(vault.recipients.contains(&pubkey));
164        assert!(!murk.recipients.contains_key(&pubkey));
165    }
166
167    #[test]
168    fn authorize_recipient_duplicate_fails() {
169        let (_, pubkey) = generate_keypair();
170        let mut vault = empty_vault();
171        vault.recipients.push(pubkey.clone());
172        let mut murk = empty_murk();
173
174        let result = authorize_recipient(&mut vault, &mut murk, &pubkey, None);
175        assert!(result.is_err());
176        assert!(result.unwrap_err().contains("already a recipient"));
177    }
178
179    #[test]
180    fn authorize_recipient_invalid_key_fails() {
181        let mut vault = empty_vault();
182        let mut murk = empty_murk();
183
184        let result = authorize_recipient(&mut vault, &mut murk, "not-a-valid-key", None);
185        assert!(result.is_err());
186        assert!(result.unwrap_err().contains("invalid public key"));
187    }
188
189    #[test]
190    fn revoke_recipient_by_pubkey() {
191        let (_, pk1) = generate_keypair();
192        let (_, pk2) = generate_keypair();
193        let mut vault = empty_vault();
194        vault.recipients = vec![pk1.clone(), pk2.clone()];
195        vault.schema.insert(
196            "KEY".into(),
197            types::SchemaEntry {
198                description: String::new(),
199                example: None,
200                tags: vec![],
201            },
202        );
203        let mut murk = empty_murk();
204        murk.recipients.insert(pk2.clone(), "bob".into());
205
206        let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
207        assert_eq!(result.display_name.as_deref(), Some("bob"));
208        assert!(!vault.recipients.contains(&pk2));
209        assert!(vault.recipients.contains(&pk1));
210        assert_eq!(result.exposed_keys, vec!["KEY"]);
211    }
212
213    #[test]
214    fn revoke_recipient_by_name() {
215        let (_, pk1) = generate_keypair();
216        let (_, pk2) = generate_keypair();
217        let mut vault = empty_vault();
218        vault.recipients = vec![pk1.clone(), pk2.clone()];
219        let mut murk = empty_murk();
220        murk.recipients.insert(pk2.clone(), "bob".into());
221
222        let result = revoke_recipient(&mut vault, &mut murk, "bob").unwrap();
223        assert_eq!(result.display_name.as_deref(), Some("bob"));
224        assert!(!vault.recipients.contains(&pk2));
225    }
226
227    #[test]
228    fn revoke_recipient_last_fails() {
229        let (_, pk) = generate_keypair();
230        let mut vault = empty_vault();
231        vault.recipients = vec![pk.clone()];
232        let mut murk = empty_murk();
233
234        let result = revoke_recipient(&mut vault, &mut murk, &pk);
235        assert!(result.is_err());
236        assert!(result.unwrap_err().contains("cannot revoke last recipient"));
237    }
238
239    #[test]
240    fn revoke_recipient_unknown_fails() {
241        let (_, pk) = generate_keypair();
242        let mut vault = empty_vault();
243        vault.recipients = vec![pk.clone()];
244        let mut murk = empty_murk();
245
246        let result = revoke_recipient(&mut vault, &mut murk, "nobody");
247        assert!(result.is_err());
248        assert!(result.unwrap_err().contains("recipient not found"));
249    }
250
251    #[test]
252    fn revoke_recipient_removes_scoped() {
253        let (_, pk1) = generate_keypair();
254        let (_, pk2) = generate_keypair();
255        let mut vault = empty_vault();
256        vault.recipients = vec![pk1.clone(), pk2.clone()];
257        vault.secrets.insert(
258            "KEY".into(),
259            types::SecretEntry {
260                shared: "ct".into(),
261                scoped: BTreeMap::from([(pk2.clone(), "scoped_ct".into())]),
262            },
263        );
264        let mut murk = empty_murk();
265        let mut scoped = HashMap::new();
266        scoped.insert(pk2.clone(), "scoped_val".into());
267        murk.scoped.insert("KEY".into(), scoped);
268
269        revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
270
271        assert!(vault.secrets["KEY"].scoped.is_empty());
272        assert!(murk.scoped["KEY"].is_empty());
273    }
274
275    #[test]
276    fn revoke_recipient_reports_exposed_keys() {
277        let (_, pk1) = generate_keypair();
278        let (_, pk2) = generate_keypair();
279        let mut vault = empty_vault();
280        vault.recipients = vec![pk1.clone(), pk2.clone()];
281        // exposed_keys returns all schema keys, so we need schema entries.
282        vault.schema.insert(
283            "DB_URL".into(),
284            types::SchemaEntry {
285                description: "db".into(),
286                example: None,
287                tags: vec![],
288            },
289        );
290        vault.schema.insert(
291            "API_KEY".into(),
292            types::SchemaEntry {
293                description: "api".into(),
294                example: None,
295                tags: vec![],
296            },
297        );
298        vault.secrets.insert(
299            "DB_URL".into(),
300            types::SecretEntry {
301                shared: "ct".into(),
302                scoped: BTreeMap::from([(pk2.clone(), "scoped_db".into())]),
303            },
304        );
305        vault.secrets.insert(
306            "API_KEY".into(),
307            types::SecretEntry {
308                shared: "ct2".into(),
309                scoped: BTreeMap::from([(pk2.clone(), "scoped_api".into())]),
310            },
311        );
312        let mut murk = empty_murk();
313        murk.scoped
314            .insert("DB_URL".into(), HashMap::from([(pk2.clone(), "v".into())]));
315        murk.scoped.insert(
316            "API_KEY".into(),
317            HashMap::from([(pk2.clone(), "v2".into())]),
318        );
319
320        let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
321        let mut keys = result.exposed_keys.clone();
322        keys.sort();
323        assert_eq!(keys, vec!["API_KEY", "DB_URL"]);
324        assert!(vault.secrets["DB_URL"].scoped.is_empty());
325        assert!(vault.secrets["API_KEY"].scoped.is_empty());
326    }
327
328    // ── list_recipients tests ──
329
330    #[test]
331    fn list_recipients_with_meta() {
332        let (secret, pubkey) = generate_keypair();
333        let (_, pk2) = generate_keypair();
334        let recipient = make_recipient(&pubkey);
335
336        let mut names = std::collections::HashMap::new();
337        names.insert(pubkey.clone(), "Alice".to_string());
338        names.insert(pk2.clone(), "Bob".to_string());
339        let meta = types::Meta {
340            recipients: names,
341            mac: String::new(),
342        };
343        let meta_json = serde_json::to_vec(&meta).unwrap();
344        let r2 = make_recipient(&pk2);
345        let meta_enc = crate::encrypt_value(&meta_json, &[recipient, r2]).unwrap();
346
347        let mut vault = empty_vault();
348        vault.recipients = vec![pubkey.clone(), pk2.clone()];
349        vault.meta = meta_enc;
350
351        let entries = list_recipients(&vault, Some(&secret));
352        assert_eq!(entries.len(), 2);
353        let me = entries.iter().find(|e| e.pubkey == pubkey).unwrap();
354        assert!(me.is_self);
355        assert_eq!(me.display_name.as_deref(), Some("Alice"));
356        let other = entries.iter().find(|e| e.pubkey == pk2).unwrap();
357        assert!(!other.is_self);
358        assert_eq!(other.display_name.as_deref(), Some("Bob"));
359    }
360
361    #[test]
362    fn list_recipients_without_key() {
363        let (_, pubkey) = generate_keypair();
364        let mut vault = empty_vault();
365        vault.recipients = vec![pubkey.clone()];
366
367        let entries = list_recipients(&vault, None);
368        assert_eq!(entries.len(), 1);
369        assert_eq!(entries[0].pubkey, pubkey);
370        assert!(entries[0].display_name.is_none());
371        assert!(!entries[0].is_self);
372    }
373
374    #[test]
375    fn list_recipients_wrong_key() {
376        let (_, pubkey) = generate_keypair();
377        let recipient = make_recipient(&pubkey);
378        let (wrong_secret, _) = generate_keypair();
379
380        let meta = types::Meta {
381            recipients: std::collections::HashMap::from([(pubkey.clone(), "Alice".into())]),
382            mac: String::new(),
383        };
384        let meta_json = serde_json::to_vec(&meta).unwrap();
385        let meta_enc = crate::encrypt_value(&meta_json, &[recipient]).unwrap();
386
387        let mut vault = empty_vault();
388        vault.recipients = vec![pubkey.clone()];
389        vault.meta = meta_enc;
390
391        let entries = list_recipients(&vault, Some(&wrong_secret));
392        assert_eq!(entries.len(), 1);
393        assert!(entries[0].display_name.is_none());
394        assert!(!entries[0].is_self);
395    }
396
397    #[test]
398    fn list_recipients_empty_vault() {
399        let vault = empty_vault();
400        let entries = list_recipients(&vault, None);
401        assert!(entries.is_empty());
402    }
403
404    #[test]
405    fn revoke_recipient_no_scoped() {
406        let (_, pk1) = generate_keypair();
407        let (_, pk2) = generate_keypair();
408        let mut vault = empty_vault();
409        vault.recipients = vec![pk1.clone(), pk2.clone()];
410        let mut murk = empty_murk();
411        murk.recipients.insert(pk2.clone(), "bob".into());
412
413        let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
414        assert_eq!(result.display_name.as_deref(), Some("bob"));
415        assert!(!vault.recipients.contains(&pk2));
416    }
417
418    #[test]
419    fn revoke_by_name_removes_all_matching_keys() {
420        let (_, pk_owner) = generate_keypair();
421        let (_, pk_ssh1) = generate_keypair();
422        let (_, pk_ssh2) = generate_keypair();
423        let mut vault = empty_vault();
424        vault.recipients = vec![pk_owner.clone(), pk_ssh1.clone(), pk_ssh2.clone()];
425        let mut murk = empty_murk();
426        murk.recipients
427            .insert(pk_ssh1.clone(), "alice@github".into());
428        murk.recipients
429            .insert(pk_ssh2.clone(), "alice@github".into());
430
431        let result = revoke_recipient(&mut vault, &mut murk, "alice@github").unwrap();
432        assert_eq!(result.display_name.as_deref(), Some("alice@github"));
433        assert!(!vault.recipients.contains(&pk_ssh1));
434        assert!(!vault.recipients.contains(&pk_ssh2));
435        assert!(vault.recipients.contains(&pk_owner));
436    }
437
438    #[test]
439    fn revoke_all_matching_blocked_if_last() {
440        let (_, pk_ssh1) = generate_keypair();
441        let (_, pk_ssh2) = generate_keypair();
442        let mut vault = empty_vault();
443        vault.recipients = vec![pk_ssh1.clone(), pk_ssh2.clone()];
444        let mut murk = empty_murk();
445        murk.recipients
446            .insert(pk_ssh1.clone(), "alice@github".into());
447        murk.recipients
448            .insert(pk_ssh2.clone(), "alice@github".into());
449
450        let result = revoke_recipient(&mut vault, &mut murk, "alice@github");
451        assert!(result.is_err());
452        assert!(result.unwrap_err().contains("cannot revoke last recipient"));
453    }
454}