Skip to main content

murk_cli/
recipients.rs

1//! Recipient management: authorize and revoke vault access.
2
3use crate::{crypto, types};
4
5/// Add a recipient to the vault. Returns an error if the pubkey is invalid or already present.
6pub fn authorize_recipient(
7    vault: &mut types::Vault,
8    murk: &mut types::Murk,
9    pubkey: &str,
10    name: Option<&str>,
11) -> Result<(), String> {
12    if crypto::parse_recipient(pubkey).is_err() {
13        return Err(format!("invalid public key: {pubkey}"));
14    }
15
16    if vault.recipients.contains(&pubkey.to_string()) {
17        return Err(format!("{pubkey} is already a recipient"));
18    }
19
20    vault.recipients.push(pubkey.into());
21
22    if let Some(n) = name {
23        murk.recipients.insert(pubkey.into(), n.into());
24    }
25
26    Ok(())
27}
28
29/// Result of revoking a recipient.
30#[derive(Debug)]
31pub struct RevokeResult {
32    /// The display name of the revoked recipient, if known.
33    pub display_name: Option<String>,
34    /// Keys the revoked recipient had access to (for rotation warnings).
35    pub exposed_keys: Vec<String>,
36}
37
38/// Remove a recipient from the vault. `recipient` can be a pubkey or a display name.
39/// Returns an error if the recipient is not found or is the last one.
40pub fn revoke_recipient(
41    vault: &mut types::Vault,
42    murk: &mut types::Murk,
43    recipient: &str,
44) -> Result<RevokeResult, String> {
45    // Resolve to pubkey.
46    let pubkey = if vault.recipients.contains(&recipient.to_string()) {
47        recipient.to_string()
48    } else {
49        murk.recipients
50            .iter()
51            .find(|(_, name)| name.as_str() == recipient)
52            .map(|(pk, _)| pk.clone())
53            .ok_or_else(|| format!("recipient not found: {recipient}"))?
54    };
55
56    if vault.recipients.len() == 1 {
57        return Err(
58            "cannot revoke last recipient — vault would become permanently inaccessible".into(),
59        );
60    }
61
62    vault.recipients.retain(|pk| pk != &pubkey);
63
64    let display_name = murk.recipients.remove(&pubkey);
65
66    // Remove their scoped entries.
67    for scoped_map in murk.scoped.values_mut() {
68        scoped_map.remove(&pubkey);
69    }
70    for entry in vault.secrets.values_mut() {
71        entry.scoped.remove(&pubkey);
72    }
73
74    let exposed_keys = vault.schema.keys().cloned().collect();
75
76    Ok(RevokeResult {
77        display_name,
78        exposed_keys,
79    })
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use crate::testutil::*;
86    use crate::types;
87    use std::collections::{BTreeMap, HashMap};
88
89    #[test]
90    fn authorize_recipient_success() {
91        let (_, pubkey) = generate_keypair();
92        let mut vault = empty_vault();
93        let mut murk = empty_murk();
94
95        let result = authorize_recipient(&mut vault, &mut murk, &pubkey, Some("alice"));
96        assert!(result.is_ok());
97        assert!(vault.recipients.contains(&pubkey));
98        assert_eq!(murk.recipients[&pubkey], "alice");
99    }
100
101    #[test]
102    fn authorize_recipient_no_name() {
103        let (_, pubkey) = generate_keypair();
104        let mut vault = empty_vault();
105        let mut murk = empty_murk();
106
107        authorize_recipient(&mut vault, &mut murk, &pubkey, None).unwrap();
108        assert!(vault.recipients.contains(&pubkey));
109        assert!(!murk.recipients.contains_key(&pubkey));
110    }
111
112    #[test]
113    fn authorize_recipient_duplicate_fails() {
114        let (_, pubkey) = generate_keypair();
115        let mut vault = empty_vault();
116        vault.recipients.push(pubkey.clone());
117        let mut murk = empty_murk();
118
119        let result = authorize_recipient(&mut vault, &mut murk, &pubkey, None);
120        assert!(result.is_err());
121        assert!(result.unwrap_err().contains("already a recipient"));
122    }
123
124    #[test]
125    fn authorize_recipient_invalid_key_fails() {
126        let mut vault = empty_vault();
127        let mut murk = empty_murk();
128
129        let result = authorize_recipient(&mut vault, &mut murk, "not-a-valid-key", None);
130        assert!(result.is_err());
131        assert!(result.unwrap_err().contains("invalid public key"));
132    }
133
134    #[test]
135    fn revoke_recipient_by_pubkey() {
136        let (_, pk1) = generate_keypair();
137        let (_, pk2) = generate_keypair();
138        let mut vault = empty_vault();
139        vault.recipients = vec![pk1.clone(), pk2.clone()];
140        vault.schema.insert(
141            "KEY".into(),
142            types::SchemaEntry {
143                description: String::new(),
144                example: None,
145                tags: vec![],
146            },
147        );
148        let mut murk = empty_murk();
149        murk.recipients.insert(pk2.clone(), "bob".into());
150
151        let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
152        assert_eq!(result.display_name.as_deref(), Some("bob"));
153        assert!(!vault.recipients.contains(&pk2));
154        assert!(vault.recipients.contains(&pk1));
155        assert_eq!(result.exposed_keys, vec!["KEY"]);
156    }
157
158    #[test]
159    fn revoke_recipient_by_name() {
160        let (_, pk1) = generate_keypair();
161        let (_, pk2) = generate_keypair();
162        let mut vault = empty_vault();
163        vault.recipients = vec![pk1.clone(), pk2.clone()];
164        let mut murk = empty_murk();
165        murk.recipients.insert(pk2.clone(), "bob".into());
166
167        let result = revoke_recipient(&mut vault, &mut murk, "bob").unwrap();
168        assert_eq!(result.display_name.as_deref(), Some("bob"));
169        assert!(!vault.recipients.contains(&pk2));
170    }
171
172    #[test]
173    fn revoke_recipient_last_fails() {
174        let (_, pk) = generate_keypair();
175        let mut vault = empty_vault();
176        vault.recipients = vec![pk.clone()];
177        let mut murk = empty_murk();
178
179        let result = revoke_recipient(&mut vault, &mut murk, &pk);
180        assert!(result.is_err());
181        assert!(result.unwrap_err().contains("cannot revoke last recipient"));
182    }
183
184    #[test]
185    fn revoke_recipient_unknown_fails() {
186        let (_, pk) = generate_keypair();
187        let mut vault = empty_vault();
188        vault.recipients = vec![pk.clone()];
189        let mut murk = empty_murk();
190
191        let result = revoke_recipient(&mut vault, &mut murk, "nobody");
192        assert!(result.is_err());
193        assert!(result.unwrap_err().contains("recipient not found"));
194    }
195
196    #[test]
197    fn revoke_recipient_removes_scoped() {
198        let (_, pk1) = generate_keypair();
199        let (_, pk2) = generate_keypair();
200        let mut vault = empty_vault();
201        vault.recipients = vec![pk1.clone(), pk2.clone()];
202        vault.secrets.insert(
203            "KEY".into(),
204            types::SecretEntry {
205                shared: "ct".into(),
206                scoped: BTreeMap::from([(pk2.clone(), "scoped_ct".into())]),
207            },
208        );
209        let mut murk = empty_murk();
210        let mut scoped = HashMap::new();
211        scoped.insert(pk2.clone(), "scoped_val".into());
212        murk.scoped.insert("KEY".into(), scoped);
213
214        revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
215
216        assert!(vault.secrets["KEY"].scoped.is_empty());
217        assert!(murk.scoped["KEY"].is_empty());
218    }
219
220    // ── New edge-case tests ──
221
222    #[test]
223    fn revoke_recipient_no_scoped() {
224        let (_, pk1) = generate_keypair();
225        let (_, pk2) = generate_keypair();
226        let mut vault = empty_vault();
227        vault.recipients = vec![pk1.clone(), pk2.clone()];
228        let mut murk = empty_murk();
229        murk.recipients.insert(pk2.clone(), "bob".into());
230
231        let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
232        assert_eq!(result.display_name.as_deref(), Some("bob"));
233        assert!(!vault.recipients.contains(&pk2));
234    }
235}