Skip to main content

murk_cli/
recipients.rs

1//! Recipient management: authorize, revoke, and list vault recipients.
2
3use crate::{crypto, types};
4
5/// Maximum number of recipients per vault.
6const MAX_RECIPIENTS: usize = 100;
7
8/// A single recipient entry with resolved display info.
9#[derive(Debug)]
10pub struct RecipientEntry {
11    pub pubkey: String,
12    pub display_name: Option<String>,
13    pub is_self: bool,
14}
15
16/// List all recipients in the vault with optional name resolution.
17///
18/// If `secret_key` is provided, decrypts meta to resolve display names
19/// and marks which recipient corresponds to the caller's key.
20pub fn list_recipients(vault: &types::Vault, secret_key: Option<&str>) -> Vec<RecipientEntry> {
21    let meta_data = secret_key.filter(|k| !k.is_empty()).and_then(|sk| {
22        let identity = crypto::parse_identity(sk).ok()?;
23        let my_pubkey = identity.pubkey_string().ok()?;
24        let meta = crate::decrypt_meta(vault, &identity)?;
25        Some((meta, my_pubkey))
26    });
27
28    vault
29        .recipients
30        .iter()
31        .map(|pk| {
32            let (display_name, is_self) = match &meta_data {
33                Some((meta, my_pubkey)) => {
34                    let name = meta.recipients.get(pk).filter(|n| !n.is_empty()).cloned();
35                    (name, pk == my_pubkey)
36                }
37                None => (None, false),
38            };
39            RecipientEntry {
40                pubkey: pk.clone(),
41                display_name,
42                is_self,
43            }
44        })
45        .collect()
46}
47
48/// Add a recipient to the vault. Returns an error if the pubkey is invalid or already present.
49pub fn authorize_recipient(
50    vault: &mut types::Vault,
51    murk: &mut types::Murk,
52    pubkey: &str,
53    name: Option<&str>,
54) -> Result<(), crate::error::MurkError> {
55    use crate::error::MurkError;
56
57    if crypto::parse_recipient(pubkey).is_err() {
58        return Err(MurkError::Recipient(format!(
59            "invalid public key: {pubkey}"
60        )));
61    }
62
63    if vault.recipients.contains(&pubkey.to_string()) {
64        return Err(MurkError::Recipient(format!(
65            "{pubkey} is already a recipient"
66        )));
67    }
68
69    if vault.recipients.len() >= MAX_RECIPIENTS {
70        return Err(MurkError::Recipient(format!(
71            "vault already has {MAX_RECIPIENTS} recipients — remove unused recipients before adding more"
72        )));
73    }
74
75    vault.recipients.push(pubkey.into());
76
77    if let Some(n) = name {
78        murk.recipients.insert(pubkey.into(), n.into());
79    }
80
81    Ok(())
82}
83
84/// Result of revoking a recipient.
85#[derive(Debug)]
86pub struct RevokeResult {
87    /// The display name of the revoked recipient, if known.
88    pub display_name: Option<String>,
89    /// Keys the revoked recipient had access to (for rotation warnings).
90    pub exposed_keys: Vec<String>,
91}
92
93/// Remove a recipient from the vault. `recipient` can be a pubkey or a display name.
94///
95/// When matched by display name, removes **all** recipients sharing that name
96/// (e.g. multiple SSH keys added via `github:username`).
97/// Returns an error if the recipient is not found or would remove the last recipient.
98pub fn revoke_recipient(
99    vault: &mut types::Vault,
100    murk: &mut types::Murk,
101    recipient: &str,
102) -> Result<RevokeResult, crate::error::MurkError> {
103    use crate::error::MurkError;
104
105    let pubkeys: Vec<String> = if vault.recipients.contains(&recipient.to_string()) {
106        vec![recipient.to_string()]
107    } else {
108        let matched: Vec<String> = murk
109            .recipients
110            .iter()
111            .filter(|(_, name)| name.as_str() == recipient)
112            .map(|(pk, _)| pk.clone())
113            .collect();
114        if matched.is_empty() {
115            return Err(MurkError::Recipient(format!(
116                "recipient not found: {recipient}"
117            )));
118        }
119        if matched.len() > 1 {
120            return Err(MurkError::Recipient(format!(
121                "ambiguous name \"{recipient}\" matches {} recipients — use a pubkey to revoke",
122                matched.len()
123            )));
124        }
125        matched
126    };
127
128    if vault.recipients.len() <= pubkeys.len() {
129        return Err(MurkError::Recipient(
130            "cannot revoke last recipient — vault would become permanently inaccessible".into(),
131        ));
132    }
133
134    let mut display_name = None;
135    for pubkey in &pubkeys {
136        vault.recipients.retain(|pk| pk != pubkey);
137
138        if let Some(name) = murk.recipients.remove(pubkey) {
139            display_name = Some(name);
140        }
141
142        // Remove their scoped entries.
143        for scoped_map in murk.scoped.values_mut() {
144            scoped_map.remove(pubkey);
145        }
146        for entry in vault.secrets.values_mut() {
147            entry.scoped.remove(pubkey);
148        }
149    }
150
151    // Only report keys the revoked recipient could actually decrypt:
152    // shared secrets (all recipients can read) + their scoped entries.
153    let exposed_keys: Vec<String> = vault
154        .secrets
155        .iter()
156        .filter(|(_, entry)| {
157            !entry.shared.is_empty() || pubkeys.iter().any(|pk| entry.scoped.contains_key(pk))
158        })
159        .map(|(key, _)| key.clone())
160        .collect();
161
162    Ok(RevokeResult {
163        display_name,
164        exposed_keys,
165    })
166}
167
168/// Truncate a pubkey for display, keeping start and end.
169pub fn truncate_pubkey(pk: &str) -> String {
170    if let Some(key_data) = pk.strip_prefix("ssh-ed25519 ") {
171        return truncate_raw(key_data);
172    }
173    if let Some(key_data) = pk.strip_prefix("ssh-rsa ") {
174        return truncate_raw(key_data);
175    }
176    truncate_raw(pk)
177}
178
179fn truncate_raw(s: &str) -> String {
180    if s.len() <= 13 {
181        return s.to_string();
182    }
183    let start: String = s.chars().take(8).collect();
184    let end: String = s
185        .chars()
186        .rev()
187        .take(4)
188        .collect::<Vec<_>>()
189        .into_iter()
190        .rev()
191        .collect();
192    format!("{start}…{end}")
193}
194
195/// Return the key type label for a pubkey string.
196pub fn key_type_label(pk: &str) -> &'static str {
197    if pk.starts_with("ssh-ed25519 ") {
198        "ed25519"
199    } else if pk.starts_with("ssh-rsa ") {
200        "rsa"
201    } else {
202        "age"
203    }
204}
205
206/// A group of recipients sharing a display name.
207pub struct RecipientGroup<'a> {
208    pub name: Option<&'a str>,
209    pub entries: Vec<&'a RecipientEntry>,
210    pub is_self: bool,
211}
212
213/// Group recipient entries by display name and format for display.
214/// Returns plain-text lines (no ANSI colors).
215pub fn format_recipient_lines(entries: &[RecipientEntry]) -> Vec<String> {
216    let has_names = entries.iter().any(|e| e.display_name.is_some());
217    if !has_names {
218        return entries.iter().map(|e| e.pubkey.clone()).collect();
219    }
220
221    let groups = group_recipients(entries);
222
223    let name_width = groups
224        .iter()
225        .map(|g| g.name.map_or(0, str::len))
226        .max()
227        .unwrap_or(0);
228
229    groups
230        .iter()
231        .map(|g| {
232            let marker = if g.is_self { "◆" } else { " " };
233            let label = g.name.unwrap_or("");
234            let label_padded = format!("{label:<name_width$}");
235            let key_type = key_type_label(&g.entries[0].pubkey);
236            let key_info = if g.entries.len() == 1 {
237                truncate_pubkey(&g.entries[0].pubkey)
238            } else {
239                format!("({} keys)", g.entries.len())
240            };
241            format!("{marker} {label_padded}  {key_info}  {key_type}")
242        })
243        .collect()
244}
245
246fn group_recipients(entries: &[RecipientEntry]) -> Vec<RecipientGroup<'_>> {
247    let mut groups: Vec<RecipientGroup<'_>> = Vec::new();
248    for entry in entries {
249        let name = entry.display_name.as_deref();
250        if let Some(group) = groups.iter_mut().find(|g| g.name == name && name.is_some()) {
251            group.entries.push(entry);
252            if entry.is_self {
253                group.is_self = true;
254            }
255        } else {
256            groups.push(RecipientGroup {
257                name,
258                entries: vec![entry],
259                is_self: entry.is_self,
260            });
261        }
262    }
263    groups
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::testutil::*;
270    use crate::types;
271    use std::collections::{BTreeMap, HashMap};
272
273    #[test]
274    fn authorize_recipient_success() {
275        let (_, pubkey) = generate_keypair();
276        let mut vault = empty_vault();
277        let mut murk = empty_murk();
278
279        let result = authorize_recipient(&mut vault, &mut murk, &pubkey, Some("alice"));
280        assert!(result.is_ok());
281        assert!(vault.recipients.contains(&pubkey));
282        assert_eq!(murk.recipients[&pubkey], "alice");
283    }
284
285    #[test]
286    fn authorize_recipient_no_name() {
287        let (_, pubkey) = generate_keypair();
288        let mut vault = empty_vault();
289        let mut murk = empty_murk();
290
291        authorize_recipient(&mut vault, &mut murk, &pubkey, None).unwrap();
292        assert!(vault.recipients.contains(&pubkey));
293        assert!(!murk.recipients.contains_key(&pubkey));
294    }
295
296    #[test]
297    fn authorize_recipient_duplicate_fails() {
298        let (_, pubkey) = generate_keypair();
299        let mut vault = empty_vault();
300        vault.recipients.push(pubkey.clone());
301        let mut murk = empty_murk();
302
303        let result = authorize_recipient(&mut vault, &mut murk, &pubkey, None);
304        assert!(result.is_err());
305        assert!(
306            result
307                .unwrap_err()
308                .to_string()
309                .contains("already a recipient")
310        );
311    }
312
313    #[test]
314    fn authorize_recipient_invalid_key_fails() {
315        let mut vault = empty_vault();
316        let mut murk = empty_murk();
317
318        let result = authorize_recipient(&mut vault, &mut murk, "not-a-valid-key", None);
319        assert!(result.is_err());
320        assert!(
321            result
322                .unwrap_err()
323                .to_string()
324                .contains("invalid public key")
325        );
326    }
327
328    #[test]
329    fn revoke_recipient_by_pubkey() {
330        let (_, pk1) = generate_keypair();
331        let (_, pk2) = generate_keypair();
332        let mut vault = empty_vault();
333        vault.recipients = vec![pk1.clone(), pk2.clone()];
334        vault.schema.insert(
335            "KEY".into(),
336            types::SchemaEntry {
337                description: String::new(),
338                example: None,
339                tags: vec![],
340                ..Default::default()
341            },
342        );
343        vault.secrets.insert(
344            "KEY".into(),
345            types::SecretEntry {
346                shared: "ciphertext".into(),
347                scoped: std::collections::BTreeMap::new(),
348            },
349        );
350        let mut murk = empty_murk();
351        murk.recipients.insert(pk2.clone(), "bob".into());
352
353        let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
354        assert_eq!(result.display_name.as_deref(), Some("bob"));
355        assert!(!vault.recipients.contains(&pk2));
356        assert!(vault.recipients.contains(&pk1));
357        assert_eq!(result.exposed_keys, vec!["KEY"]);
358    }
359
360    #[test]
361    fn revoke_recipient_by_name() {
362        let (_, pk1) = generate_keypair();
363        let (_, pk2) = generate_keypair();
364        let mut vault = empty_vault();
365        vault.recipients = vec![pk1.clone(), pk2.clone()];
366        let mut murk = empty_murk();
367        murk.recipients.insert(pk2.clone(), "bob".into());
368
369        let result = revoke_recipient(&mut vault, &mut murk, "bob").unwrap();
370        assert_eq!(result.display_name.as_deref(), Some("bob"));
371        assert!(!vault.recipients.contains(&pk2));
372    }
373
374    #[test]
375    fn revoke_recipient_last_fails() {
376        let (_, pk) = generate_keypair();
377        let mut vault = empty_vault();
378        vault.recipients = vec![pk.clone()];
379        let mut murk = empty_murk();
380
381        let result = revoke_recipient(&mut vault, &mut murk, &pk);
382        assert!(result.is_err());
383        assert!(
384            result
385                .unwrap_err()
386                .to_string()
387                .contains("cannot revoke last recipient")
388        );
389    }
390
391    #[test]
392    fn revoke_recipient_unknown_fails() {
393        let (_, pk) = generate_keypair();
394        let mut vault = empty_vault();
395        vault.recipients = vec![pk.clone()];
396        let mut murk = empty_murk();
397
398        let result = revoke_recipient(&mut vault, &mut murk, "nobody");
399        assert!(result.is_err());
400        assert!(
401            result
402                .unwrap_err()
403                .to_string()
404                .contains("recipient not found")
405        );
406    }
407
408    #[test]
409    fn revoke_recipient_removes_scoped() {
410        let (_, pk1) = generate_keypair();
411        let (_, pk2) = generate_keypair();
412        let mut vault = empty_vault();
413        vault.recipients = vec![pk1.clone(), pk2.clone()];
414        vault.secrets.insert(
415            "KEY".into(),
416            types::SecretEntry {
417                shared: "ct".into(),
418                scoped: BTreeMap::from([(pk2.clone(), "scoped_ct".into())]),
419            },
420        );
421        let mut murk = empty_murk();
422        let mut scoped = HashMap::new();
423        scoped.insert(pk2.clone(), "scoped_val".into());
424        murk.scoped.insert("KEY".into(), scoped);
425
426        revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
427
428        assert!(vault.secrets["KEY"].scoped.is_empty());
429        assert!(murk.scoped["KEY"].is_empty());
430    }
431
432    #[test]
433    fn revoke_recipient_reports_exposed_keys() {
434        let (_, pk1) = generate_keypair();
435        let (_, pk2) = generate_keypair();
436        let mut vault = empty_vault();
437        vault.recipients = vec![pk1.clone(), pk2.clone()];
438        // exposed_keys returns all schema keys, so we need schema entries.
439        vault.schema.insert(
440            "DB_URL".into(),
441            types::SchemaEntry {
442                description: "db".into(),
443                example: None,
444                tags: vec![],
445                ..Default::default()
446            },
447        );
448        vault.schema.insert(
449            "API_KEY".into(),
450            types::SchemaEntry {
451                description: "api".into(),
452                example: None,
453                tags: vec![],
454                ..Default::default()
455            },
456        );
457        vault.secrets.insert(
458            "DB_URL".into(),
459            types::SecretEntry {
460                shared: "ct".into(),
461                scoped: BTreeMap::from([(pk2.clone(), "scoped_db".into())]),
462            },
463        );
464        vault.secrets.insert(
465            "API_KEY".into(),
466            types::SecretEntry {
467                shared: "ct2".into(),
468                scoped: BTreeMap::from([(pk2.clone(), "scoped_api".into())]),
469            },
470        );
471        let mut murk = empty_murk();
472        murk.scoped
473            .insert("DB_URL".into(), HashMap::from([(pk2.clone(), "v".into())]));
474        murk.scoped.insert(
475            "API_KEY".into(),
476            HashMap::from([(pk2.clone(), "v2".into())]),
477        );
478
479        let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
480        let mut keys = result.exposed_keys.clone();
481        keys.sort();
482        assert_eq!(keys, vec!["API_KEY", "DB_URL"]);
483        assert!(vault.secrets["DB_URL"].scoped.is_empty());
484        assert!(vault.secrets["API_KEY"].scoped.is_empty());
485    }
486
487    // ── list_recipients tests ──
488
489    #[test]
490    fn list_recipients_with_meta() {
491        let (secret, pubkey) = generate_keypair();
492        let (_, pk2) = generate_keypair();
493        let recipient = make_recipient(&pubkey);
494
495        let mut names = std::collections::HashMap::new();
496        names.insert(pubkey.clone(), "Alice".to_string());
497        names.insert(pk2.clone(), "Bob".to_string());
498        let meta = types::Meta {
499            recipients: names,
500            mac: String::new(),
501            mac_key: None,
502            github_pins: HashMap::new(),
503        };
504        let meta_json = serde_json::to_vec(&meta).unwrap();
505        let r2 = make_recipient(&pk2);
506        let meta_enc = crate::encrypt_value(&meta_json, &[recipient, r2]).unwrap();
507
508        let mut vault = empty_vault();
509        vault.recipients = vec![pubkey.clone(), pk2.clone()];
510        vault.meta = meta_enc;
511
512        let entries = list_recipients(&vault, Some(&secret));
513        assert_eq!(entries.len(), 2);
514        let me = entries.iter().find(|e| e.pubkey == pubkey).unwrap();
515        assert!(me.is_self);
516        assert_eq!(me.display_name.as_deref(), Some("Alice"));
517        let other = entries.iter().find(|e| e.pubkey == pk2).unwrap();
518        assert!(!other.is_self);
519        assert_eq!(other.display_name.as_deref(), Some("Bob"));
520    }
521
522    #[test]
523    fn list_recipients_without_key() {
524        let (_, pubkey) = generate_keypair();
525        let mut vault = empty_vault();
526        vault.recipients = vec![pubkey.clone()];
527
528        let entries = list_recipients(&vault, None);
529        assert_eq!(entries.len(), 1);
530        assert_eq!(entries[0].pubkey, pubkey);
531        assert!(entries[0].display_name.is_none());
532        assert!(!entries[0].is_self);
533    }
534
535    #[test]
536    fn list_recipients_wrong_key() {
537        let (_, pubkey) = generate_keypair();
538        let recipient = make_recipient(&pubkey);
539        let (wrong_secret, _) = generate_keypair();
540
541        let meta = types::Meta {
542            recipients: std::collections::HashMap::from([(pubkey.clone(), "Alice".into())]),
543            mac: String::new(),
544            mac_key: None,
545            github_pins: HashMap::new(),
546        };
547        let meta_json = serde_json::to_vec(&meta).unwrap();
548        let meta_enc = crate::encrypt_value(&meta_json, &[recipient]).unwrap();
549
550        let mut vault = empty_vault();
551        vault.recipients = vec![pubkey.clone()];
552        vault.meta = meta_enc;
553
554        let entries = list_recipients(&vault, Some(&wrong_secret));
555        assert_eq!(entries.len(), 1);
556        assert!(entries[0].display_name.is_none());
557        assert!(!entries[0].is_self);
558    }
559
560    #[test]
561    fn list_recipients_empty_vault() {
562        let vault = empty_vault();
563        let entries = list_recipients(&vault, None);
564        assert!(entries.is_empty());
565    }
566
567    #[test]
568    fn revoke_recipient_no_scoped() {
569        let (_, pk1) = generate_keypair();
570        let (_, pk2) = generate_keypair();
571        let mut vault = empty_vault();
572        vault.recipients = vec![pk1.clone(), pk2.clone()];
573        let mut murk = empty_murk();
574        murk.recipients.insert(pk2.clone(), "bob".into());
575
576        let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
577        assert_eq!(result.display_name.as_deref(), Some("bob"));
578        assert!(!vault.recipients.contains(&pk2));
579    }
580
581    #[test]
582    fn revoke_by_name_rejects_ambiguous_match() {
583        let (_, pk_owner) = generate_keypair();
584        let (_, pk_ssh1) = generate_keypair();
585        let (_, pk_ssh2) = generate_keypair();
586        let mut vault = empty_vault();
587        vault.recipients = vec![pk_owner.clone(), pk_ssh1.clone(), pk_ssh2.clone()];
588        let mut murk = empty_murk();
589        murk.recipients
590            .insert(pk_ssh1.clone(), "alice@github".into());
591        murk.recipients
592            .insert(pk_ssh2.clone(), "alice@github".into());
593
594        let result = revoke_recipient(&mut vault, &mut murk, "alice@github");
595        assert!(result.is_err());
596        assert!(result.unwrap_err().to_string().contains("ambiguous name"));
597    }
598
599    // ── formatting tests ──
600
601    #[test]
602    fn truncate_age_key() {
603        let pk = "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p";
604        let truncated = truncate_pubkey(pk);
605        assert!(truncated.len() < pk.len());
606        assert!(truncated.starts_with("age1ql3z"));
607        assert!(truncated.contains('…'));
608    }
609
610    #[test]
611    fn truncate_ssh_key() {
612        let pk = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGVsample";
613        let truncated = truncate_pubkey(pk);
614        assert!(!truncated.starts_with("ssh-ed25519"));
615        assert!(truncated.contains('…'));
616    }
617
618    #[test]
619    fn truncate_short_key_unchanged() {
620        assert_eq!(truncate_pubkey("age1short"), "age1short");
621    }
622
623    #[test]
624    fn key_type_labels() {
625        assert_eq!(key_type_label("age1abc"), "age");
626        assert_eq!(key_type_label("ssh-ed25519 AAAA"), "ed25519");
627        assert_eq!(key_type_label("ssh-rsa AAAA"), "rsa");
628    }
629
630    #[test]
631    fn format_recipients_no_names() {
632        let entries = vec![
633            RecipientEntry {
634                pubkey: "age1abc".into(),
635                display_name: None,
636                is_self: false,
637            },
638            RecipientEntry {
639                pubkey: "age1xyz".into(),
640                display_name: None,
641                is_self: false,
642            },
643        ];
644        let lines = format_recipient_lines(&entries);
645        assert_eq!(lines, vec!["age1abc", "age1xyz"]);
646    }
647
648    #[test]
649    fn format_recipients_with_names() {
650        let entries = vec![
651            RecipientEntry {
652                pubkey: "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p".into(),
653                display_name: Some("alice".into()),
654                is_self: true,
655            },
656            RecipientEntry {
657                pubkey: "age1xyz7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p".into(),
658                display_name: Some("bob".into()),
659                is_self: false,
660            },
661        ];
662        let lines = format_recipient_lines(&entries);
663        assert_eq!(lines.len(), 2);
664        assert!(lines[0].starts_with("◆"));
665        assert!(lines[0].contains("alice"));
666        assert!(lines[1].starts_with(" "));
667        assert!(lines[1].contains("bob"));
668    }
669
670    #[test]
671    fn format_recipients_groups_multi_key() {
672        let entries = vec![
673            RecipientEntry {
674                pubkey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGVkey1sample".into(),
675                display_name: Some("alice@github".into()),
676                is_self: false,
677            },
678            RecipientEntry {
679                pubkey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGVkey2sample".into(),
680                display_name: Some("alice@github".into()),
681                is_self: false,
682            },
683        ];
684        let lines = format_recipient_lines(&entries);
685        assert_eq!(lines.len(), 1);
686        assert!(lines[0].contains("(2 keys)"));
687    }
688}