Skip to main content

murk_cli/
groups.rs

1//! Named recipient groups: the access-segmentation primitive.
2//!
3//! A group is a named subset of the vault's recipients. Secrets assigned to a
4//! group are encrypted only to its members, so a leaked member key can't read
5//! secrets outside that member's groups. Membership lives in the encrypted meta
6//! (see [`crate::types::Meta::groups`]) so org structure doesn't leak, and is
7//! covered by the keyed MAC so it can't be tampered with undetected.
8//!
9//! Two group names are reserved as routing aliases for the existing tiers and
10//! are never stored: `everyone` (the shared value, encrypted to all recipients)
11//! and `me` (a personal scoped value, encrypted to the caller only).
12
13use crate::error::MurkError;
14use crate::types;
15
16/// Reserved group names. These route to the shared/private tiers instead of a
17/// stored group, so a real group can't take these names.
18pub const RESERVED_GROUP_NAMES: &[&str] = &["everyone", "me", "all", "self", "mine", "shared"];
19
20/// True if `name` is a reserved routing alias.
21pub fn is_reserved(name: &str) -> bool {
22    RESERVED_GROUP_NAMES.contains(&name)
23}
24
25/// Validate a group name: 1–64 chars of `[A-Za-z0-9_-]`, not reserved.
26pub fn validate_group_name(name: &str) -> Result<(), MurkError> {
27    if name.is_empty() {
28        return Err(MurkError::Group("group name cannot be empty".into()));
29    }
30    if name.len() > 64 {
31        return Err(MurkError::Group(
32            "group name too long (max 64 characters)".into(),
33        ));
34    }
35    if !name
36        .chars()
37        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
38    {
39        return Err(MurkError::Group(format!(
40            "invalid group name \"{name}\" — use letters, digits, dashes, underscores"
41        )));
42    }
43    if is_reserved(name) {
44        return Err(MurkError::Group(format!(
45            "\"{name}\" is a reserved name (it routes to the {name} tier, not a group)"
46        )));
47    }
48    Ok(())
49}
50
51/// Create a new empty group, seeded with the creator as its first member so
52/// they can always read and re-encrypt it. Errors if the name is invalid or the
53/// group already exists.
54pub fn create_group(
55    murk: &mut types::Murk,
56    name: &str,
57    creator_pubkey: &str,
58) -> Result<(), MurkError> {
59    validate_group_name(name)?;
60    if murk.groups.contains_key(name) {
61        return Err(MurkError::Group(format!("group already exists: {name}")));
62    }
63    murk.groups
64        .insert(name.into(), vec![creator_pubkey.to_string()]);
65    Ok(())
66}
67
68/// Delete a group. Refuses if any secret is still assigned to it — the caller
69/// should reassign or remove those secrets first, so no data is silently
70/// orphaned (group ciphertext would become unreadable).
71pub fn delete_group(
72    vault: &types::Vault,
73    murk: &mut types::Murk,
74    name: &str,
75) -> Result<(), MurkError> {
76    if !murk.groups.contains_key(name) {
77        return Err(MurkError::Group(format!("group not found: {name}")));
78    }
79    let assigned: Vec<&str> = vault
80        .secrets
81        .iter()
82        .filter(|(_, e)| e.grouped.contains_key(name))
83        .map(|(k, _)| k.as_str())
84        .collect();
85    if !assigned.is_empty() {
86        return Err(MurkError::Group(format!(
87            "group \"{name}\" still has {} secret(s) assigned ({}) — reassign or remove them first",
88            assigned.len(),
89            assigned.join(", ")
90        )));
91    }
92    murk.groups.remove(name);
93    Ok(())
94}
95
96/// Resolve a member spec (a pubkey or a display name) to a recipient pubkey.
97/// The result must be an authorized recipient of the vault.
98pub fn resolve_member(
99    vault: &types::Vault,
100    murk: &types::Murk,
101    spec: &str,
102) -> Result<String, MurkError> {
103    if vault.recipients.iter().any(|pk| pk == spec) {
104        return Ok(spec.to_string());
105    }
106    let matched: Vec<&String> = murk
107        .recipients
108        .iter()
109        .filter(|(_, name)| name.as_str() == spec)
110        .map(|(pk, _)| pk)
111        .collect();
112    match matched.as_slice() {
113        [] => Err(MurkError::Group(format!(
114            "no recipient matches \"{spec}\" — authorize them first with `murk circle authorize`"
115        ))),
116        [pk] => Ok((*pk).clone()),
117        _ => Err(MurkError::Group(format!(
118            "ambiguous name \"{spec}\" matches {} recipients — use a pubkey",
119            matched.len()
120        ))),
121    }
122}
123
124/// Add a member to a group. The group must exist, the operator must already be
125/// a member (so they can re-encrypt the group's secrets), and the new member
126/// must be an authorized recipient. Returns true if the member was added (false
127/// if already present).
128pub fn add_member(
129    murk: &mut types::Murk,
130    name: &str,
131    member_pubkey: &str,
132    operator_pubkey: &str,
133) -> Result<bool, MurkError> {
134    let members = murk
135        .groups
136        .get_mut(name)
137        .ok_or_else(|| MurkError::Group(format!("group not found: {name}")))?;
138    if !members.iter().any(|pk| pk == operator_pubkey) {
139        return Err(MurkError::Group(format!(
140            "you must be a member of group \"{name}\" to modify it"
141        )));
142    }
143    if members.iter().any(|pk| pk == member_pubkey) {
144        return Ok(false);
145    }
146    members.push(member_pubkey.to_string());
147    Ok(true)
148}
149
150/// Remove a member from a group. The operator must be a member. Refuses to
151/// remove the last member (the group's secrets would become unreadable).
152/// Returns true if the member was removed (false if not present).
153pub fn remove_member(
154    murk: &mut types::Murk,
155    name: &str,
156    member_pubkey: &str,
157    operator_pubkey: &str,
158) -> Result<bool, MurkError> {
159    let members = murk
160        .groups
161        .get_mut(name)
162        .ok_or_else(|| MurkError::Group(format!("group not found: {name}")))?;
163    if !members.iter().any(|pk| pk == operator_pubkey) {
164        return Err(MurkError::Group(format!(
165            "you must be a member of group \"{name}\" to modify it"
166        )));
167    }
168    if !members.iter().any(|pk| pk == member_pubkey) {
169        return Ok(false);
170    }
171    if members.len() == 1 {
172        return Err(MurkError::Group(format!(
173            "cannot remove the last member of group \"{name}\" — delete the group instead"
174        )));
175    }
176    members.retain(|pk| pk != member_pubkey);
177    Ok(true)
178}
179
180#[cfg(test)]
181mod tests {
182    use std::collections::BTreeMap;
183
184    use super::*;
185    use crate::testutil::{empty_murk, empty_vault};
186
187    #[test]
188    fn validate_rejects_reserved_and_invalid() {
189        assert!(validate_group_name("prod").is_ok());
190        assert!(validate_group_name("dev-team_1").is_ok());
191        assert!(validate_group_name("me").is_err());
192        assert!(validate_group_name("everyone").is_err());
193        assert!(validate_group_name("").is_err());
194        assert!(validate_group_name("has space").is_err());
195        assert!(validate_group_name(&"x".repeat(65)).is_err());
196    }
197
198    #[test]
199    fn create_seeds_creator_as_member() {
200        let mut murk = empty_murk();
201        create_group(&mut murk, "prod", "age1alice").unwrap();
202        assert_eq!(murk.groups["prod"], vec!["age1alice".to_string()]);
203        // Duplicate create fails.
204        assert!(create_group(&mut murk, "prod", "age1alice").is_err());
205    }
206
207    #[test]
208    fn add_member_requires_operator_membership() {
209        let mut murk = empty_murk();
210        create_group(&mut murk, "prod", "age1alice").unwrap();
211        // A non-member operator can't modify the group.
212        assert!(add_member(&mut murk, "prod", "age1bob", "age1carol").is_err());
213        // A member can.
214        assert!(add_member(&mut murk, "prod", "age1bob", "age1alice").unwrap());
215        // Adding an existing member is a no-op.
216        assert!(!add_member(&mut murk, "prod", "age1bob", "age1alice").unwrap());
217        assert_eq!(murk.groups["prod"].len(), 2);
218    }
219
220    #[test]
221    fn remove_member_refuses_last() {
222        let mut murk = empty_murk();
223        create_group(&mut murk, "prod", "age1alice").unwrap();
224        add_member(&mut murk, "prod", "age1bob", "age1alice").unwrap();
225        assert!(remove_member(&mut murk, "prod", "age1bob", "age1alice").unwrap());
226        // Only alice left — removing her is refused.
227        assert!(remove_member(&mut murk, "prod", "age1alice", "age1alice").is_err());
228    }
229
230    #[test]
231    fn delete_refuses_when_secrets_assigned() {
232        let mut vault = empty_vault();
233        let mut murk = empty_murk();
234        create_group(&mut murk, "prod", "age1alice").unwrap();
235
236        // No secrets yet — delete works.
237        delete_group(&vault, &mut murk, "prod").unwrap();
238
239        // Re-create and assign a secret; now delete is refused.
240        create_group(&mut murk, "prod", "age1alice").unwrap();
241        vault.secrets.insert(
242            "K".into(),
243            types::SecretEntry {
244                grouped: BTreeMap::from([("prod".into(), "ct".into())]),
245                ..Default::default()
246            },
247        );
248        assert!(delete_group(&vault, &mut murk, "prod").is_err());
249    }
250
251    #[test]
252    fn resolve_member_by_name_and_pubkey() {
253        let vault = types::Vault {
254            recipients: vec!["age1alice".into()],
255            ..empty_vault()
256        };
257        let mut murk = empty_murk();
258        murk.recipients.insert("age1alice".into(), "alice".into());
259
260        assert_eq!(
261            resolve_member(&vault, &murk, "age1alice").unwrap(),
262            "age1alice"
263        );
264        assert_eq!(resolve_member(&vault, &murk, "alice").unwrap(), "age1alice");
265        assert!(resolve_member(&vault, &murk, "nobody").is_err());
266    }
267}