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.to_public().to_string();
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/// Returns an error if the recipient is not found or is the last one.
80pub fn revoke_recipient(
81    vault: &mut types::Vault,
82    murk: &mut types::Murk,
83    recipient: &str,
84) -> Result<RevokeResult, String> {
85    // Resolve to pubkey.
86    let pubkey = if vault.recipients.contains(&recipient.to_string()) {
87        recipient.to_string()
88    } else {
89        murk.recipients
90            .iter()
91            .find(|(_, name)| name.as_str() == recipient)
92            .map(|(pk, _)| pk.clone())
93            .ok_or_else(|| format!("recipient not found: {recipient}"))?
94    };
95
96    if vault.recipients.len() == 1 {
97        return Err(
98            "cannot revoke last recipient — vault would become permanently inaccessible".into(),
99        );
100    }
101
102    vault.recipients.retain(|pk| pk != &pubkey);
103
104    let display_name = murk.recipients.remove(&pubkey);
105
106    // Remove their scoped entries.
107    for scoped_map in murk.scoped.values_mut() {
108        scoped_map.remove(&pubkey);
109    }
110    for entry in vault.secrets.values_mut() {
111        entry.scoped.remove(&pubkey);
112    }
113
114    let exposed_keys = vault.schema.keys().cloned().collect();
115
116    Ok(RevokeResult {
117        display_name,
118        exposed_keys,
119    })
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::testutil::*;
126    use crate::types;
127    use std::collections::{BTreeMap, HashMap};
128
129    #[test]
130    fn authorize_recipient_success() {
131        let (_, pubkey) = generate_keypair();
132        let mut vault = empty_vault();
133        let mut murk = empty_murk();
134
135        let result = authorize_recipient(&mut vault, &mut murk, &pubkey, Some("alice"));
136        assert!(result.is_ok());
137        assert!(vault.recipients.contains(&pubkey));
138        assert_eq!(murk.recipients[&pubkey], "alice");
139    }
140
141    #[test]
142    fn authorize_recipient_no_name() {
143        let (_, pubkey) = generate_keypair();
144        let mut vault = empty_vault();
145        let mut murk = empty_murk();
146
147        authorize_recipient(&mut vault, &mut murk, &pubkey, None).unwrap();
148        assert!(vault.recipients.contains(&pubkey));
149        assert!(!murk.recipients.contains_key(&pubkey));
150    }
151
152    #[test]
153    fn authorize_recipient_duplicate_fails() {
154        let (_, pubkey) = generate_keypair();
155        let mut vault = empty_vault();
156        vault.recipients.push(pubkey.clone());
157        let mut murk = empty_murk();
158
159        let result = authorize_recipient(&mut vault, &mut murk, &pubkey, None);
160        assert!(result.is_err());
161        assert!(result.unwrap_err().contains("already a recipient"));
162    }
163
164    #[test]
165    fn authorize_recipient_invalid_key_fails() {
166        let mut vault = empty_vault();
167        let mut murk = empty_murk();
168
169        let result = authorize_recipient(&mut vault, &mut murk, "not-a-valid-key", None);
170        assert!(result.is_err());
171        assert!(result.unwrap_err().contains("invalid public key"));
172    }
173
174    #[test]
175    fn revoke_recipient_by_pubkey() {
176        let (_, pk1) = generate_keypair();
177        let (_, pk2) = generate_keypair();
178        let mut vault = empty_vault();
179        vault.recipients = vec![pk1.clone(), pk2.clone()];
180        vault.schema.insert(
181            "KEY".into(),
182            types::SchemaEntry {
183                description: String::new(),
184                example: None,
185                tags: vec![],
186            },
187        );
188        let mut murk = empty_murk();
189        murk.recipients.insert(pk2.clone(), "bob".into());
190
191        let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
192        assert_eq!(result.display_name.as_deref(), Some("bob"));
193        assert!(!vault.recipients.contains(&pk2));
194        assert!(vault.recipients.contains(&pk1));
195        assert_eq!(result.exposed_keys, vec!["KEY"]);
196    }
197
198    #[test]
199    fn revoke_recipient_by_name() {
200        let (_, pk1) = generate_keypair();
201        let (_, pk2) = generate_keypair();
202        let mut vault = empty_vault();
203        vault.recipients = vec![pk1.clone(), pk2.clone()];
204        let mut murk = empty_murk();
205        murk.recipients.insert(pk2.clone(), "bob".into());
206
207        let result = revoke_recipient(&mut vault, &mut murk, "bob").unwrap();
208        assert_eq!(result.display_name.as_deref(), Some("bob"));
209        assert!(!vault.recipients.contains(&pk2));
210    }
211
212    #[test]
213    fn revoke_recipient_last_fails() {
214        let (_, pk) = generate_keypair();
215        let mut vault = empty_vault();
216        vault.recipients = vec![pk.clone()];
217        let mut murk = empty_murk();
218
219        let result = revoke_recipient(&mut vault, &mut murk, &pk);
220        assert!(result.is_err());
221        assert!(result.unwrap_err().contains("cannot revoke last recipient"));
222    }
223
224    #[test]
225    fn revoke_recipient_unknown_fails() {
226        let (_, pk) = generate_keypair();
227        let mut vault = empty_vault();
228        vault.recipients = vec![pk.clone()];
229        let mut murk = empty_murk();
230
231        let result = revoke_recipient(&mut vault, &mut murk, "nobody");
232        assert!(result.is_err());
233        assert!(result.unwrap_err().contains("recipient not found"));
234    }
235
236    #[test]
237    fn revoke_recipient_removes_scoped() {
238        let (_, pk1) = generate_keypair();
239        let (_, pk2) = generate_keypair();
240        let mut vault = empty_vault();
241        vault.recipients = vec![pk1.clone(), pk2.clone()];
242        vault.secrets.insert(
243            "KEY".into(),
244            types::SecretEntry {
245                shared: "ct".into(),
246                scoped: BTreeMap::from([(pk2.clone(), "scoped_ct".into())]),
247            },
248        );
249        let mut murk = empty_murk();
250        let mut scoped = HashMap::new();
251        scoped.insert(pk2.clone(), "scoped_val".into());
252        murk.scoped.insert("KEY".into(), scoped);
253
254        revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
255
256        assert!(vault.secrets["KEY"].scoped.is_empty());
257        assert!(murk.scoped["KEY"].is_empty());
258    }
259
260    #[test]
261    fn revoke_recipient_reports_exposed_keys() {
262        let (_, pk1) = generate_keypair();
263        let (_, pk2) = generate_keypair();
264        let mut vault = empty_vault();
265        vault.recipients = vec![pk1.clone(), pk2.clone()];
266        // exposed_keys returns all schema keys, so we need schema entries.
267        vault.schema.insert(
268            "DB_URL".into(),
269            types::SchemaEntry {
270                description: "db".into(),
271                example: None,
272                tags: vec![],
273            },
274        );
275        vault.schema.insert(
276            "API_KEY".into(),
277            types::SchemaEntry {
278                description: "api".into(),
279                example: None,
280                tags: vec![],
281            },
282        );
283        vault.secrets.insert(
284            "DB_URL".into(),
285            types::SecretEntry {
286                shared: "ct".into(),
287                scoped: BTreeMap::from([(pk2.clone(), "scoped_db".into())]),
288            },
289        );
290        vault.secrets.insert(
291            "API_KEY".into(),
292            types::SecretEntry {
293                shared: "ct2".into(),
294                scoped: BTreeMap::from([(pk2.clone(), "scoped_api".into())]),
295            },
296        );
297        let mut murk = empty_murk();
298        murk.scoped
299            .insert("DB_URL".into(), HashMap::from([(pk2.clone(), "v".into())]));
300        murk.scoped.insert(
301            "API_KEY".into(),
302            HashMap::from([(pk2.clone(), "v2".into())]),
303        );
304
305        let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
306        let mut keys = result.exposed_keys.clone();
307        keys.sort();
308        assert_eq!(keys, vec!["API_KEY", "DB_URL"]);
309        assert!(vault.secrets["DB_URL"].scoped.is_empty());
310        assert!(vault.secrets["API_KEY"].scoped.is_empty());
311    }
312
313    // ── list_recipients tests ──
314
315    #[test]
316    fn list_recipients_with_meta() {
317        let (secret, pubkey) = generate_keypair();
318        let (_, pk2) = generate_keypair();
319        let recipient = make_recipient(&pubkey);
320
321        let mut names = std::collections::HashMap::new();
322        names.insert(pubkey.clone(), "Alice".to_string());
323        names.insert(pk2.clone(), "Bob".to_string());
324        let meta = types::Meta {
325            recipients: names,
326            mac: String::new(),
327        };
328        let meta_json = serde_json::to_vec(&meta).unwrap();
329        let r2 = make_recipient(&pk2);
330        let meta_enc = crate::encrypt_value(&meta_json, &[recipient, r2]).unwrap();
331
332        let mut vault = empty_vault();
333        vault.recipients = vec![pubkey.clone(), pk2.clone()];
334        vault.meta = meta_enc;
335
336        let entries = list_recipients(&vault, Some(&secret));
337        assert_eq!(entries.len(), 2);
338        let me = entries.iter().find(|e| e.pubkey == pubkey).unwrap();
339        assert!(me.is_self);
340        assert_eq!(me.display_name.as_deref(), Some("Alice"));
341        let other = entries.iter().find(|e| e.pubkey == pk2).unwrap();
342        assert!(!other.is_self);
343        assert_eq!(other.display_name.as_deref(), Some("Bob"));
344    }
345
346    #[test]
347    fn list_recipients_without_key() {
348        let (_, pubkey) = generate_keypair();
349        let mut vault = empty_vault();
350        vault.recipients = vec![pubkey.clone()];
351
352        let entries = list_recipients(&vault, None);
353        assert_eq!(entries.len(), 1);
354        assert_eq!(entries[0].pubkey, pubkey);
355        assert!(entries[0].display_name.is_none());
356        assert!(!entries[0].is_self);
357    }
358
359    #[test]
360    fn list_recipients_wrong_key() {
361        let (_, pubkey) = generate_keypair();
362        let recipient = make_recipient(&pubkey);
363        let (wrong_secret, _) = generate_keypair();
364
365        let meta = types::Meta {
366            recipients: std::collections::HashMap::from([(pubkey.clone(), "Alice".into())]),
367            mac: String::new(),
368        };
369        let meta_json = serde_json::to_vec(&meta).unwrap();
370        let meta_enc = crate::encrypt_value(&meta_json, &[recipient]).unwrap();
371
372        let mut vault = empty_vault();
373        vault.recipients = vec![pubkey.clone()];
374        vault.meta = meta_enc;
375
376        let entries = list_recipients(&vault, Some(&wrong_secret));
377        assert_eq!(entries.len(), 1);
378        assert!(entries[0].display_name.is_none());
379        assert!(!entries[0].is_self);
380    }
381
382    #[test]
383    fn list_recipients_empty_vault() {
384        let vault = empty_vault();
385        let entries = list_recipients(&vault, None);
386        assert!(entries.is_empty());
387    }
388
389    #[test]
390    fn revoke_recipient_no_scoped() {
391        let (_, pk1) = generate_keypair();
392        let (_, pk2) = generate_keypair();
393        let mut vault = empty_vault();
394        vault.recipients = vec![pk1.clone(), pk2.clone()];
395        let mut murk = empty_murk();
396        murk.recipients.insert(pk2.clone(), "bob".into());
397
398        let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
399        assert_eq!(result.display_name.as_deref(), Some("bob"));
400        assert!(!vault.recipients.contains(&pk2));
401    }
402}