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            },
341        );
342        vault.secrets.insert(
343            "KEY".into(),
344            types::SecretEntry {
345                shared: "ciphertext".into(),
346                scoped: std::collections::BTreeMap::new(),
347            },
348        );
349        let mut murk = empty_murk();
350        murk.recipients.insert(pk2.clone(), "bob".into());
351
352        let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
353        assert_eq!(result.display_name.as_deref(), Some("bob"));
354        assert!(!vault.recipients.contains(&pk2));
355        assert!(vault.recipients.contains(&pk1));
356        assert_eq!(result.exposed_keys, vec!["KEY"]);
357    }
358
359    #[test]
360    fn revoke_recipient_by_name() {
361        let (_, pk1) = generate_keypair();
362        let (_, pk2) = generate_keypair();
363        let mut vault = empty_vault();
364        vault.recipients = vec![pk1.clone(), pk2.clone()];
365        let mut murk = empty_murk();
366        murk.recipients.insert(pk2.clone(), "bob".into());
367
368        let result = revoke_recipient(&mut vault, &mut murk, "bob").unwrap();
369        assert_eq!(result.display_name.as_deref(), Some("bob"));
370        assert!(!vault.recipients.contains(&pk2));
371    }
372
373    #[test]
374    fn revoke_recipient_last_fails() {
375        let (_, pk) = generate_keypair();
376        let mut vault = empty_vault();
377        vault.recipients = vec![pk.clone()];
378        let mut murk = empty_murk();
379
380        let result = revoke_recipient(&mut vault, &mut murk, &pk);
381        assert!(result.is_err());
382        assert!(
383            result
384                .unwrap_err()
385                .to_string()
386                .contains("cannot revoke last recipient")
387        );
388    }
389
390    #[test]
391    fn revoke_recipient_unknown_fails() {
392        let (_, pk) = generate_keypair();
393        let mut vault = empty_vault();
394        vault.recipients = vec![pk.clone()];
395        let mut murk = empty_murk();
396
397        let result = revoke_recipient(&mut vault, &mut murk, "nobody");
398        assert!(result.is_err());
399        assert!(
400            result
401                .unwrap_err()
402                .to_string()
403                .contains("recipient not found")
404        );
405    }
406
407    #[test]
408    fn revoke_recipient_removes_scoped() {
409        let (_, pk1) = generate_keypair();
410        let (_, pk2) = generate_keypair();
411        let mut vault = empty_vault();
412        vault.recipients = vec![pk1.clone(), pk2.clone()];
413        vault.secrets.insert(
414            "KEY".into(),
415            types::SecretEntry {
416                shared: "ct".into(),
417                scoped: BTreeMap::from([(pk2.clone(), "scoped_ct".into())]),
418            },
419        );
420        let mut murk = empty_murk();
421        let mut scoped = HashMap::new();
422        scoped.insert(pk2.clone(), "scoped_val".into());
423        murk.scoped.insert("KEY".into(), scoped);
424
425        revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
426
427        assert!(vault.secrets["KEY"].scoped.is_empty());
428        assert!(murk.scoped["KEY"].is_empty());
429    }
430
431    #[test]
432    fn revoke_recipient_reports_exposed_keys() {
433        let (_, pk1) = generate_keypair();
434        let (_, pk2) = generate_keypair();
435        let mut vault = empty_vault();
436        vault.recipients = vec![pk1.clone(), pk2.clone()];
437        // exposed_keys returns all schema keys, so we need schema entries.
438        vault.schema.insert(
439            "DB_URL".into(),
440            types::SchemaEntry {
441                description: "db".into(),
442                example: None,
443                tags: vec![],
444            },
445        );
446        vault.schema.insert(
447            "API_KEY".into(),
448            types::SchemaEntry {
449                description: "api".into(),
450                example: None,
451                tags: vec![],
452            },
453        );
454        vault.secrets.insert(
455            "DB_URL".into(),
456            types::SecretEntry {
457                shared: "ct".into(),
458                scoped: BTreeMap::from([(pk2.clone(), "scoped_db".into())]),
459            },
460        );
461        vault.secrets.insert(
462            "API_KEY".into(),
463            types::SecretEntry {
464                shared: "ct2".into(),
465                scoped: BTreeMap::from([(pk2.clone(), "scoped_api".into())]),
466            },
467        );
468        let mut murk = empty_murk();
469        murk.scoped
470            .insert("DB_URL".into(), HashMap::from([(pk2.clone(), "v".into())]));
471        murk.scoped.insert(
472            "API_KEY".into(),
473            HashMap::from([(pk2.clone(), "v2".into())]),
474        );
475
476        let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
477        let mut keys = result.exposed_keys.clone();
478        keys.sort();
479        assert_eq!(keys, vec!["API_KEY", "DB_URL"]);
480        assert!(vault.secrets["DB_URL"].scoped.is_empty());
481        assert!(vault.secrets["API_KEY"].scoped.is_empty());
482    }
483
484    // ── list_recipients tests ──
485
486    #[test]
487    fn list_recipients_with_meta() {
488        let (secret, pubkey) = generate_keypair();
489        let (_, pk2) = generate_keypair();
490        let recipient = make_recipient(&pubkey);
491
492        let mut names = std::collections::HashMap::new();
493        names.insert(pubkey.clone(), "Alice".to_string());
494        names.insert(pk2.clone(), "Bob".to_string());
495        let meta = types::Meta {
496            recipients: names,
497            mac: String::new(),
498            hmac_key: None,
499        };
500        let meta_json = serde_json::to_vec(&meta).unwrap();
501        let r2 = make_recipient(&pk2);
502        let meta_enc = crate::encrypt_value(&meta_json, &[recipient, r2]).unwrap();
503
504        let mut vault = empty_vault();
505        vault.recipients = vec![pubkey.clone(), pk2.clone()];
506        vault.meta = meta_enc;
507
508        let entries = list_recipients(&vault, Some(&secret));
509        assert_eq!(entries.len(), 2);
510        let me = entries.iter().find(|e| e.pubkey == pubkey).unwrap();
511        assert!(me.is_self);
512        assert_eq!(me.display_name.as_deref(), Some("Alice"));
513        let other = entries.iter().find(|e| e.pubkey == pk2).unwrap();
514        assert!(!other.is_self);
515        assert_eq!(other.display_name.as_deref(), Some("Bob"));
516    }
517
518    #[test]
519    fn list_recipients_without_key() {
520        let (_, pubkey) = generate_keypair();
521        let mut vault = empty_vault();
522        vault.recipients = vec![pubkey.clone()];
523
524        let entries = list_recipients(&vault, None);
525        assert_eq!(entries.len(), 1);
526        assert_eq!(entries[0].pubkey, pubkey);
527        assert!(entries[0].display_name.is_none());
528        assert!(!entries[0].is_self);
529    }
530
531    #[test]
532    fn list_recipients_wrong_key() {
533        let (_, pubkey) = generate_keypair();
534        let recipient = make_recipient(&pubkey);
535        let (wrong_secret, _) = generate_keypair();
536
537        let meta = types::Meta {
538            recipients: std::collections::HashMap::from([(pubkey.clone(), "Alice".into())]),
539            mac: String::new(),
540            hmac_key: None,
541        };
542        let meta_json = serde_json::to_vec(&meta).unwrap();
543        let meta_enc = crate::encrypt_value(&meta_json, &[recipient]).unwrap();
544
545        let mut vault = empty_vault();
546        vault.recipients = vec![pubkey.clone()];
547        vault.meta = meta_enc;
548
549        let entries = list_recipients(&vault, Some(&wrong_secret));
550        assert_eq!(entries.len(), 1);
551        assert!(entries[0].display_name.is_none());
552        assert!(!entries[0].is_self);
553    }
554
555    #[test]
556    fn list_recipients_empty_vault() {
557        let vault = empty_vault();
558        let entries = list_recipients(&vault, None);
559        assert!(entries.is_empty());
560    }
561
562    #[test]
563    fn revoke_recipient_no_scoped() {
564        let (_, pk1) = generate_keypair();
565        let (_, pk2) = generate_keypair();
566        let mut vault = empty_vault();
567        vault.recipients = vec![pk1.clone(), pk2.clone()];
568        let mut murk = empty_murk();
569        murk.recipients.insert(pk2.clone(), "bob".into());
570
571        let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
572        assert_eq!(result.display_name.as_deref(), Some("bob"));
573        assert!(!vault.recipients.contains(&pk2));
574    }
575
576    #[test]
577    fn revoke_by_name_rejects_ambiguous_match() {
578        let (_, pk_owner) = generate_keypair();
579        let (_, pk_ssh1) = generate_keypair();
580        let (_, pk_ssh2) = generate_keypair();
581        let mut vault = empty_vault();
582        vault.recipients = vec![pk_owner.clone(), pk_ssh1.clone(), pk_ssh2.clone()];
583        let mut murk = empty_murk();
584        murk.recipients
585            .insert(pk_ssh1.clone(), "alice@github".into());
586        murk.recipients
587            .insert(pk_ssh2.clone(), "alice@github".into());
588
589        let result = revoke_recipient(&mut vault, &mut murk, "alice@github");
590        assert!(result.is_err());
591        assert!(result.unwrap_err().to_string().contains("ambiguous name"));
592    }
593
594    // ── formatting tests ──
595
596    #[test]
597    fn truncate_age_key() {
598        let pk = "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p";
599        let truncated = truncate_pubkey(pk);
600        assert!(truncated.len() < pk.len());
601        assert!(truncated.starts_with("age1ql3z"));
602        assert!(truncated.contains('…'));
603    }
604
605    #[test]
606    fn truncate_ssh_key() {
607        let pk = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGVsample";
608        let truncated = truncate_pubkey(pk);
609        assert!(!truncated.starts_with("ssh-ed25519"));
610        assert!(truncated.contains('…'));
611    }
612
613    #[test]
614    fn truncate_short_key_unchanged() {
615        assert_eq!(truncate_pubkey("age1short"), "age1short");
616    }
617
618    #[test]
619    fn key_type_labels() {
620        assert_eq!(key_type_label("age1abc"), "age");
621        assert_eq!(key_type_label("ssh-ed25519 AAAA"), "ed25519");
622        assert_eq!(key_type_label("ssh-rsa AAAA"), "rsa");
623    }
624
625    #[test]
626    fn format_recipients_no_names() {
627        let entries = vec![
628            RecipientEntry {
629                pubkey: "age1abc".into(),
630                display_name: None,
631                is_self: false,
632            },
633            RecipientEntry {
634                pubkey: "age1xyz".into(),
635                display_name: None,
636                is_self: false,
637            },
638        ];
639        let lines = format_recipient_lines(&entries);
640        assert_eq!(lines, vec!["age1abc", "age1xyz"]);
641    }
642
643    #[test]
644    fn format_recipients_with_names() {
645        let entries = vec![
646            RecipientEntry {
647                pubkey: "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p".into(),
648                display_name: Some("alice".into()),
649                is_self: true,
650            },
651            RecipientEntry {
652                pubkey: "age1xyz7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p".into(),
653                display_name: Some("bob".into()),
654                is_self: false,
655            },
656        ];
657        let lines = format_recipient_lines(&entries);
658        assert_eq!(lines.len(), 2);
659        assert!(lines[0].starts_with("◆"));
660        assert!(lines[0].contains("alice"));
661        assert!(lines[1].starts_with(" "));
662        assert!(lines[1].contains("bob"));
663    }
664
665    #[test]
666    fn format_recipients_groups_multi_key() {
667        let entries = vec![
668            RecipientEntry {
669                pubkey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGVkey1sample".into(),
670                display_name: Some("alice@github".into()),
671                is_self: false,
672            },
673            RecipientEntry {
674                pubkey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGVkey2sample".into(),
675                display_name: Some("alice@github".into()),
676                is_self: false,
677            },
678        ];
679        let lines = format_recipient_lines(&entries);
680        assert_eq!(lines.len(), 1);
681        assert!(lines[0].contains("(2 keys)"));
682    }
683}