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/// Truncate a pubkey for display, keeping start and end.
138pub fn truncate_pubkey(pk: &str) -> String {
139    if let Some(key_data) = pk.strip_prefix("ssh-ed25519 ") {
140        return truncate_raw(key_data);
141    }
142    if let Some(key_data) = pk.strip_prefix("ssh-rsa ") {
143        return truncate_raw(key_data);
144    }
145    truncate_raw(pk)
146}
147
148fn truncate_raw(s: &str) -> String {
149    if s.len() <= 13 {
150        return s.to_string();
151    }
152    let start: String = s.chars().take(8).collect();
153    let end: String = s
154        .chars()
155        .rev()
156        .take(4)
157        .collect::<Vec<_>>()
158        .into_iter()
159        .rev()
160        .collect();
161    format!("{start}…{end}")
162}
163
164/// Return the key type label for a pubkey string.
165pub fn key_type_label(pk: &str) -> &'static str {
166    if pk.starts_with("ssh-ed25519 ") {
167        "ed25519"
168    } else if pk.starts_with("ssh-rsa ") {
169        "rsa"
170    } else {
171        "age"
172    }
173}
174
175/// A group of recipients sharing a display name.
176pub struct RecipientGroup<'a> {
177    pub name: Option<&'a str>,
178    pub entries: Vec<&'a RecipientEntry>,
179    pub is_self: bool,
180}
181
182/// Group recipient entries by display name and format for display.
183/// Returns plain-text lines (no ANSI colors).
184pub fn format_recipient_lines(entries: &[RecipientEntry]) -> Vec<String> {
185    let has_names = entries.iter().any(|e| e.display_name.is_some());
186    if !has_names {
187        return entries.iter().map(|e| e.pubkey.clone()).collect();
188    }
189
190    let groups = group_recipients(entries);
191
192    let name_width = groups
193        .iter()
194        .map(|g| g.name.map_or(0, str::len))
195        .max()
196        .unwrap_or(0);
197
198    groups
199        .iter()
200        .map(|g| {
201            let marker = if g.is_self { "◆" } else { " " };
202            let label = g.name.unwrap_or("");
203            let label_padded = format!("{label:<name_width$}");
204            let key_type = key_type_label(&g.entries[0].pubkey);
205            let key_info = if g.entries.len() == 1 {
206                truncate_pubkey(&g.entries[0].pubkey)
207            } else {
208                format!("({} keys)", g.entries.len())
209            };
210            format!("{marker} {label_padded}  {key_info}  {key_type}")
211        })
212        .collect()
213}
214
215fn group_recipients(entries: &[RecipientEntry]) -> Vec<RecipientGroup<'_>> {
216    let mut groups: Vec<RecipientGroup<'_>> = Vec::new();
217    for entry in entries {
218        let name = entry.display_name.as_deref();
219        if let Some(group) = groups.iter_mut().find(|g| g.name == name && name.is_some()) {
220            group.entries.push(entry);
221            if entry.is_self {
222                group.is_self = true;
223            }
224        } else {
225            groups.push(RecipientGroup {
226                name,
227                entries: vec![entry],
228                is_self: entry.is_self,
229            });
230        }
231    }
232    groups
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::testutil::*;
239    use crate::types;
240    use std::collections::{BTreeMap, HashMap};
241
242    #[test]
243    fn authorize_recipient_success() {
244        let (_, pubkey) = generate_keypair();
245        let mut vault = empty_vault();
246        let mut murk = empty_murk();
247
248        let result = authorize_recipient(&mut vault, &mut murk, &pubkey, Some("alice"));
249        assert!(result.is_ok());
250        assert!(vault.recipients.contains(&pubkey));
251        assert_eq!(murk.recipients[&pubkey], "alice");
252    }
253
254    #[test]
255    fn authorize_recipient_no_name() {
256        let (_, pubkey) = generate_keypair();
257        let mut vault = empty_vault();
258        let mut murk = empty_murk();
259
260        authorize_recipient(&mut vault, &mut murk, &pubkey, None).unwrap();
261        assert!(vault.recipients.contains(&pubkey));
262        assert!(!murk.recipients.contains_key(&pubkey));
263    }
264
265    #[test]
266    fn authorize_recipient_duplicate_fails() {
267        let (_, pubkey) = generate_keypair();
268        let mut vault = empty_vault();
269        vault.recipients.push(pubkey.clone());
270        let mut murk = empty_murk();
271
272        let result = authorize_recipient(&mut vault, &mut murk, &pubkey, None);
273        assert!(result.is_err());
274        assert!(result.unwrap_err().contains("already a recipient"));
275    }
276
277    #[test]
278    fn authorize_recipient_invalid_key_fails() {
279        let mut vault = empty_vault();
280        let mut murk = empty_murk();
281
282        let result = authorize_recipient(&mut vault, &mut murk, "not-a-valid-key", None);
283        assert!(result.is_err());
284        assert!(result.unwrap_err().contains("invalid public key"));
285    }
286
287    #[test]
288    fn revoke_recipient_by_pubkey() {
289        let (_, pk1) = generate_keypair();
290        let (_, pk2) = generate_keypair();
291        let mut vault = empty_vault();
292        vault.recipients = vec![pk1.clone(), pk2.clone()];
293        vault.schema.insert(
294            "KEY".into(),
295            types::SchemaEntry {
296                description: String::new(),
297                example: None,
298                tags: vec![],
299            },
300        );
301        let mut murk = empty_murk();
302        murk.recipients.insert(pk2.clone(), "bob".into());
303
304        let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
305        assert_eq!(result.display_name.as_deref(), Some("bob"));
306        assert!(!vault.recipients.contains(&pk2));
307        assert!(vault.recipients.contains(&pk1));
308        assert_eq!(result.exposed_keys, vec!["KEY"]);
309    }
310
311    #[test]
312    fn revoke_recipient_by_name() {
313        let (_, pk1) = generate_keypair();
314        let (_, pk2) = generate_keypair();
315        let mut vault = empty_vault();
316        vault.recipients = vec![pk1.clone(), pk2.clone()];
317        let mut murk = empty_murk();
318        murk.recipients.insert(pk2.clone(), "bob".into());
319
320        let result = revoke_recipient(&mut vault, &mut murk, "bob").unwrap();
321        assert_eq!(result.display_name.as_deref(), Some("bob"));
322        assert!(!vault.recipients.contains(&pk2));
323    }
324
325    #[test]
326    fn revoke_recipient_last_fails() {
327        let (_, pk) = generate_keypair();
328        let mut vault = empty_vault();
329        vault.recipients = vec![pk.clone()];
330        let mut murk = empty_murk();
331
332        let result = revoke_recipient(&mut vault, &mut murk, &pk);
333        assert!(result.is_err());
334        assert!(result.unwrap_err().contains("cannot revoke last recipient"));
335    }
336
337    #[test]
338    fn revoke_recipient_unknown_fails() {
339        let (_, pk) = generate_keypair();
340        let mut vault = empty_vault();
341        vault.recipients = vec![pk.clone()];
342        let mut murk = empty_murk();
343
344        let result = revoke_recipient(&mut vault, &mut murk, "nobody");
345        assert!(result.is_err());
346        assert!(result.unwrap_err().contains("recipient not found"));
347    }
348
349    #[test]
350    fn revoke_recipient_removes_scoped() {
351        let (_, pk1) = generate_keypair();
352        let (_, pk2) = generate_keypair();
353        let mut vault = empty_vault();
354        vault.recipients = vec![pk1.clone(), pk2.clone()];
355        vault.secrets.insert(
356            "KEY".into(),
357            types::SecretEntry {
358                shared: "ct".into(),
359                scoped: BTreeMap::from([(pk2.clone(), "scoped_ct".into())]),
360            },
361        );
362        let mut murk = empty_murk();
363        let mut scoped = HashMap::new();
364        scoped.insert(pk2.clone(), "scoped_val".into());
365        murk.scoped.insert("KEY".into(), scoped);
366
367        revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
368
369        assert!(vault.secrets["KEY"].scoped.is_empty());
370        assert!(murk.scoped["KEY"].is_empty());
371    }
372
373    #[test]
374    fn revoke_recipient_reports_exposed_keys() {
375        let (_, pk1) = generate_keypair();
376        let (_, pk2) = generate_keypair();
377        let mut vault = empty_vault();
378        vault.recipients = vec![pk1.clone(), pk2.clone()];
379        // exposed_keys returns all schema keys, so we need schema entries.
380        vault.schema.insert(
381            "DB_URL".into(),
382            types::SchemaEntry {
383                description: "db".into(),
384                example: None,
385                tags: vec![],
386            },
387        );
388        vault.schema.insert(
389            "API_KEY".into(),
390            types::SchemaEntry {
391                description: "api".into(),
392                example: None,
393                tags: vec![],
394            },
395        );
396        vault.secrets.insert(
397            "DB_URL".into(),
398            types::SecretEntry {
399                shared: "ct".into(),
400                scoped: BTreeMap::from([(pk2.clone(), "scoped_db".into())]),
401            },
402        );
403        vault.secrets.insert(
404            "API_KEY".into(),
405            types::SecretEntry {
406                shared: "ct2".into(),
407                scoped: BTreeMap::from([(pk2.clone(), "scoped_api".into())]),
408            },
409        );
410        let mut murk = empty_murk();
411        murk.scoped
412            .insert("DB_URL".into(), HashMap::from([(pk2.clone(), "v".into())]));
413        murk.scoped.insert(
414            "API_KEY".into(),
415            HashMap::from([(pk2.clone(), "v2".into())]),
416        );
417
418        let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
419        let mut keys = result.exposed_keys.clone();
420        keys.sort();
421        assert_eq!(keys, vec!["API_KEY", "DB_URL"]);
422        assert!(vault.secrets["DB_URL"].scoped.is_empty());
423        assert!(vault.secrets["API_KEY"].scoped.is_empty());
424    }
425
426    // ── list_recipients tests ──
427
428    #[test]
429    fn list_recipients_with_meta() {
430        let (secret, pubkey) = generate_keypair();
431        let (_, pk2) = generate_keypair();
432        let recipient = make_recipient(&pubkey);
433
434        let mut names = std::collections::HashMap::new();
435        names.insert(pubkey.clone(), "Alice".to_string());
436        names.insert(pk2.clone(), "Bob".to_string());
437        let meta = types::Meta {
438            recipients: names,
439            mac: String::new(),
440            hmac_key: None,
441        };
442        let meta_json = serde_json::to_vec(&meta).unwrap();
443        let r2 = make_recipient(&pk2);
444        let meta_enc = crate::encrypt_value(&meta_json, &[recipient, r2]).unwrap();
445
446        let mut vault = empty_vault();
447        vault.recipients = vec![pubkey.clone(), pk2.clone()];
448        vault.meta = meta_enc;
449
450        let entries = list_recipients(&vault, Some(&secret));
451        assert_eq!(entries.len(), 2);
452        let me = entries.iter().find(|e| e.pubkey == pubkey).unwrap();
453        assert!(me.is_self);
454        assert_eq!(me.display_name.as_deref(), Some("Alice"));
455        let other = entries.iter().find(|e| e.pubkey == pk2).unwrap();
456        assert!(!other.is_self);
457        assert_eq!(other.display_name.as_deref(), Some("Bob"));
458    }
459
460    #[test]
461    fn list_recipients_without_key() {
462        let (_, pubkey) = generate_keypair();
463        let mut vault = empty_vault();
464        vault.recipients = vec![pubkey.clone()];
465
466        let entries = list_recipients(&vault, None);
467        assert_eq!(entries.len(), 1);
468        assert_eq!(entries[0].pubkey, pubkey);
469        assert!(entries[0].display_name.is_none());
470        assert!(!entries[0].is_self);
471    }
472
473    #[test]
474    fn list_recipients_wrong_key() {
475        let (_, pubkey) = generate_keypair();
476        let recipient = make_recipient(&pubkey);
477        let (wrong_secret, _) = generate_keypair();
478
479        let meta = types::Meta {
480            recipients: std::collections::HashMap::from([(pubkey.clone(), "Alice".into())]),
481            mac: String::new(),
482            hmac_key: None,
483        };
484        let meta_json = serde_json::to_vec(&meta).unwrap();
485        let meta_enc = crate::encrypt_value(&meta_json, &[recipient]).unwrap();
486
487        let mut vault = empty_vault();
488        vault.recipients = vec![pubkey.clone()];
489        vault.meta = meta_enc;
490
491        let entries = list_recipients(&vault, Some(&wrong_secret));
492        assert_eq!(entries.len(), 1);
493        assert!(entries[0].display_name.is_none());
494        assert!(!entries[0].is_self);
495    }
496
497    #[test]
498    fn list_recipients_empty_vault() {
499        let vault = empty_vault();
500        let entries = list_recipients(&vault, None);
501        assert!(entries.is_empty());
502    }
503
504    #[test]
505    fn revoke_recipient_no_scoped() {
506        let (_, pk1) = generate_keypair();
507        let (_, pk2) = generate_keypair();
508        let mut vault = empty_vault();
509        vault.recipients = vec![pk1.clone(), pk2.clone()];
510        let mut murk = empty_murk();
511        murk.recipients.insert(pk2.clone(), "bob".into());
512
513        let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
514        assert_eq!(result.display_name.as_deref(), Some("bob"));
515        assert!(!vault.recipients.contains(&pk2));
516    }
517
518    #[test]
519    fn revoke_by_name_removes_all_matching_keys() {
520        let (_, pk_owner) = generate_keypair();
521        let (_, pk_ssh1) = generate_keypair();
522        let (_, pk_ssh2) = generate_keypair();
523        let mut vault = empty_vault();
524        vault.recipients = vec![pk_owner.clone(), pk_ssh1.clone(), pk_ssh2.clone()];
525        let mut murk = empty_murk();
526        murk.recipients
527            .insert(pk_ssh1.clone(), "alice@github".into());
528        murk.recipients
529            .insert(pk_ssh2.clone(), "alice@github".into());
530
531        let result = revoke_recipient(&mut vault, &mut murk, "alice@github").unwrap();
532        assert_eq!(result.display_name.as_deref(), Some("alice@github"));
533        assert!(!vault.recipients.contains(&pk_ssh1));
534        assert!(!vault.recipients.contains(&pk_ssh2));
535        assert!(vault.recipients.contains(&pk_owner));
536    }
537
538    #[test]
539    fn revoke_all_matching_blocked_if_last() {
540        let (_, pk_ssh1) = generate_keypair();
541        let (_, pk_ssh2) = generate_keypair();
542        let mut vault = empty_vault();
543        vault.recipients = vec![pk_ssh1.clone(), pk_ssh2.clone()];
544        let mut murk = empty_murk();
545        murk.recipients
546            .insert(pk_ssh1.clone(), "alice@github".into());
547        murk.recipients
548            .insert(pk_ssh2.clone(), "alice@github".into());
549
550        let result = revoke_recipient(&mut vault, &mut murk, "alice@github");
551        assert!(result.is_err());
552        assert!(result.unwrap_err().contains("cannot revoke last recipient"));
553    }
554
555    // ── formatting tests ──
556
557    #[test]
558    fn truncate_age_key() {
559        let pk = "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p";
560        let truncated = truncate_pubkey(pk);
561        assert!(truncated.len() < pk.len());
562        assert!(truncated.starts_with("age1ql3z"));
563        assert!(truncated.contains('…'));
564    }
565
566    #[test]
567    fn truncate_ssh_key() {
568        let pk = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGVsample";
569        let truncated = truncate_pubkey(pk);
570        assert!(!truncated.starts_with("ssh-ed25519"));
571        assert!(truncated.contains('…'));
572    }
573
574    #[test]
575    fn truncate_short_key_unchanged() {
576        assert_eq!(truncate_pubkey("age1short"), "age1short");
577    }
578
579    #[test]
580    fn key_type_labels() {
581        assert_eq!(key_type_label("age1abc"), "age");
582        assert_eq!(key_type_label("ssh-ed25519 AAAA"), "ed25519");
583        assert_eq!(key_type_label("ssh-rsa AAAA"), "rsa");
584    }
585
586    #[test]
587    fn format_recipients_no_names() {
588        let entries = vec![
589            RecipientEntry {
590                pubkey: "age1abc".into(),
591                display_name: None,
592                is_self: false,
593            },
594            RecipientEntry {
595                pubkey: "age1xyz".into(),
596                display_name: None,
597                is_self: false,
598            },
599        ];
600        let lines = format_recipient_lines(&entries);
601        assert_eq!(lines, vec!["age1abc", "age1xyz"]);
602    }
603
604    #[test]
605    fn format_recipients_with_names() {
606        let entries = vec![
607            RecipientEntry {
608                pubkey: "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p".into(),
609                display_name: Some("alice".into()),
610                is_self: true,
611            },
612            RecipientEntry {
613                pubkey: "age1xyz7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p".into(),
614                display_name: Some("bob".into()),
615                is_self: false,
616            },
617        ];
618        let lines = format_recipient_lines(&entries);
619        assert_eq!(lines.len(), 2);
620        assert!(lines[0].starts_with("◆"));
621        assert!(lines[0].contains("alice"));
622        assert!(lines[1].starts_with(" "));
623        assert!(lines[1].contains("bob"));
624    }
625
626    #[test]
627    fn format_recipients_groups_multi_key() {
628        let entries = vec![
629            RecipientEntry {
630                pubkey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGVkey1sample".into(),
631                display_name: Some("alice@github".into()),
632                is_self: false,
633            },
634            RecipientEntry {
635                pubkey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGVkey2sample".into(),
636                display_name: Some("alice@github".into()),
637                is_self: false,
638            },
639        ];
640        let lines = format_recipient_lines(&entries);
641        assert_eq!(lines.len(), 1);
642        assert!(lines[0].contains("(2 keys)"));
643    }
644}