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