use crate::{crypto, types};
const MAX_RECIPIENTS: usize = 100;
#[derive(Debug)]
pub struct RecipientEntry {
pub pubkey: String,
pub display_name: Option<String>,
pub is_self: bool,
}
pub fn list_recipients(vault: &types::Vault, secret_key: Option<&str>) -> Vec<RecipientEntry> {
let meta_data = secret_key.filter(|k| !k.is_empty()).and_then(|sk| {
let identity = crypto::parse_identity(sk).ok()?;
let my_pubkey = identity.pubkey_string().ok()?;
let meta = crate::decrypt_meta(vault, &identity)?;
Some((meta, my_pubkey))
});
vault
.recipients
.iter()
.map(|pk| {
let (display_name, is_self) = match &meta_data {
Some((meta, my_pubkey)) => {
let name = meta.recipients.get(pk).filter(|n| !n.is_empty()).cloned();
(name, pk == my_pubkey)
}
None => (None, false),
};
RecipientEntry {
pubkey: pk.clone(),
display_name,
is_self,
}
})
.collect()
}
pub fn authorize_recipient(
vault: &mut types::Vault,
murk: &mut types::Murk,
pubkey: &str,
name: Option<&str>,
) -> Result<(), crate::error::MurkError> {
use crate::error::MurkError;
if crypto::parse_recipient(pubkey).is_err() {
return Err(MurkError::Recipient(format!(
"invalid public key: {pubkey}"
)));
}
if vault.recipients.contains(&pubkey.to_string()) {
return Err(MurkError::Recipient(format!(
"{pubkey} is already a recipient"
)));
}
if vault.recipients.len() >= MAX_RECIPIENTS {
return Err(MurkError::Recipient(format!(
"vault already has {MAX_RECIPIENTS} recipients — remove unused recipients before adding more"
)));
}
vault.recipients.push(pubkey.into());
if let Some(n) = name {
murk.recipients.insert(pubkey.into(), n.into());
}
Ok(())
}
#[derive(Debug)]
pub struct RevokeResult {
pub display_name: Option<String>,
pub exposed_keys: Vec<String>,
}
pub fn revoke_recipient(
vault: &mut types::Vault,
murk: &mut types::Murk,
recipient: &str,
) -> Result<RevokeResult, crate::error::MurkError> {
use crate::error::MurkError;
let pubkeys: Vec<String> = if vault.recipients.contains(&recipient.to_string()) {
vec![recipient.to_string()]
} else {
let matched: Vec<String> = murk
.recipients
.iter()
.filter(|(_, name)| name.as_str() == recipient)
.map(|(pk, _)| pk.clone())
.collect();
if matched.is_empty() {
return Err(MurkError::Recipient(format!(
"recipient not found: {recipient}"
)));
}
if matched.len() > 1 {
return Err(MurkError::Recipient(format!(
"ambiguous name \"{recipient}\" matches {} recipients — use a pubkey to revoke",
matched.len()
)));
}
matched
};
if vault.recipients.len() <= pubkeys.len() {
return Err(MurkError::Recipient(
"cannot revoke last recipient — vault would become permanently inaccessible".into(),
));
}
let mut display_name = None;
for pubkey in &pubkeys {
vault.recipients.retain(|pk| pk != pubkey);
if let Some(name) = murk.recipients.remove(pubkey) {
display_name = Some(name);
}
for scoped_map in murk.scoped.values_mut() {
scoped_map.remove(pubkey);
}
for entry in vault.secrets.values_mut() {
entry.scoped.remove(pubkey);
}
}
let exposed_keys: Vec<String> = vault
.secrets
.iter()
.filter(|(_, entry)| {
!entry.shared.is_empty() || pubkeys.iter().any(|pk| entry.scoped.contains_key(pk))
})
.map(|(key, _)| key.clone())
.collect();
Ok(RevokeResult {
display_name,
exposed_keys,
})
}
pub fn truncate_pubkey(pk: &str) -> String {
if let Some(key_data) = pk.strip_prefix("ssh-ed25519 ") {
return truncate_raw(key_data);
}
if let Some(key_data) = pk.strip_prefix("ssh-rsa ") {
return truncate_raw(key_data);
}
truncate_raw(pk)
}
fn truncate_raw(s: &str) -> String {
if s.len() <= 13 {
return s.to_string();
}
let start: String = s.chars().take(8).collect();
let end: String = s
.chars()
.rev()
.take(4)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("{start}…{end}")
}
pub fn key_type_label(pk: &str) -> &'static str {
if pk.starts_with("ssh-ed25519 ") {
"ed25519"
} else if pk.starts_with("ssh-rsa ") {
"rsa"
} else {
"age"
}
}
pub struct RecipientGroup<'a> {
pub name: Option<&'a str>,
pub entries: Vec<&'a RecipientEntry>,
pub is_self: bool,
}
pub fn format_recipient_lines(entries: &[RecipientEntry]) -> Vec<String> {
let has_names = entries.iter().any(|e| e.display_name.is_some());
if !has_names {
return entries.iter().map(|e| e.pubkey.clone()).collect();
}
let groups = group_recipients(entries);
let name_width = groups
.iter()
.map(|g| g.name.map_or(0, str::len))
.max()
.unwrap_or(0);
groups
.iter()
.map(|g| {
let marker = if g.is_self { "◆" } else { " " };
let label = g.name.unwrap_or("");
let label_padded = format!("{label:<name_width$}");
let key_type = key_type_label(&g.entries[0].pubkey);
let key_info = if g.entries.len() == 1 {
truncate_pubkey(&g.entries[0].pubkey)
} else {
format!("({} keys)", g.entries.len())
};
format!("{marker} {label_padded} {key_info} {key_type}")
})
.collect()
}
fn group_recipients(entries: &[RecipientEntry]) -> Vec<RecipientGroup<'_>> {
let mut groups: Vec<RecipientGroup<'_>> = Vec::new();
for entry in entries {
let name = entry.display_name.as_deref();
if let Some(group) = groups.iter_mut().find(|g| g.name == name && name.is_some()) {
group.entries.push(entry);
if entry.is_self {
group.is_self = true;
}
} else {
groups.push(RecipientGroup {
name,
entries: vec![entry],
is_self: entry.is_self,
});
}
}
groups
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use crate::types;
use std::collections::{BTreeMap, HashMap};
#[test]
fn authorize_recipient_success() {
let (_, pubkey) = generate_keypair();
let mut vault = empty_vault();
let mut murk = empty_murk();
let result = authorize_recipient(&mut vault, &mut murk, &pubkey, Some("alice"));
assert!(result.is_ok());
assert!(vault.recipients.contains(&pubkey));
assert_eq!(murk.recipients[&pubkey], "alice");
}
#[test]
fn authorize_recipient_no_name() {
let (_, pubkey) = generate_keypair();
let mut vault = empty_vault();
let mut murk = empty_murk();
authorize_recipient(&mut vault, &mut murk, &pubkey, None).unwrap();
assert!(vault.recipients.contains(&pubkey));
assert!(!murk.recipients.contains_key(&pubkey));
}
#[test]
fn authorize_recipient_duplicate_fails() {
let (_, pubkey) = generate_keypair();
let mut vault = empty_vault();
vault.recipients.push(pubkey.clone());
let mut murk = empty_murk();
let result = authorize_recipient(&mut vault, &mut murk, &pubkey, None);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("already a recipient")
);
}
#[test]
fn authorize_recipient_invalid_key_fails() {
let mut vault = empty_vault();
let mut murk = empty_murk();
let result = authorize_recipient(&mut vault, &mut murk, "not-a-valid-key", None);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("invalid public key")
);
}
#[test]
fn revoke_recipient_by_pubkey() {
let (_, pk1) = generate_keypair();
let (_, pk2) = generate_keypair();
let mut vault = empty_vault();
vault.recipients = vec![pk1.clone(), pk2.clone()];
vault.schema.insert(
"KEY".into(),
types::SchemaEntry {
description: String::new(),
example: None,
tags: vec![],
..Default::default()
},
);
vault.secrets.insert(
"KEY".into(),
types::SecretEntry {
shared: "ciphertext".into(),
scoped: std::collections::BTreeMap::new(),
},
);
let mut murk = empty_murk();
murk.recipients.insert(pk2.clone(), "bob".into());
let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
assert_eq!(result.display_name.as_deref(), Some("bob"));
assert!(!vault.recipients.contains(&pk2));
assert!(vault.recipients.contains(&pk1));
assert_eq!(result.exposed_keys, vec!["KEY"]);
}
#[test]
fn revoke_recipient_by_name() {
let (_, pk1) = generate_keypair();
let (_, pk2) = generate_keypair();
let mut vault = empty_vault();
vault.recipients = vec![pk1.clone(), pk2.clone()];
let mut murk = empty_murk();
murk.recipients.insert(pk2.clone(), "bob".into());
let result = revoke_recipient(&mut vault, &mut murk, "bob").unwrap();
assert_eq!(result.display_name.as_deref(), Some("bob"));
assert!(!vault.recipients.contains(&pk2));
}
#[test]
fn revoke_recipient_last_fails() {
let (_, pk) = generate_keypair();
let mut vault = empty_vault();
vault.recipients = vec![pk.clone()];
let mut murk = empty_murk();
let result = revoke_recipient(&mut vault, &mut murk, &pk);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("cannot revoke last recipient")
);
}
#[test]
fn revoke_recipient_unknown_fails() {
let (_, pk) = generate_keypair();
let mut vault = empty_vault();
vault.recipients = vec![pk.clone()];
let mut murk = empty_murk();
let result = revoke_recipient(&mut vault, &mut murk, "nobody");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("recipient not found")
);
}
#[test]
fn revoke_recipient_removes_scoped() {
let (_, pk1) = generate_keypair();
let (_, pk2) = generate_keypair();
let mut vault = empty_vault();
vault.recipients = vec![pk1.clone(), pk2.clone()];
vault.secrets.insert(
"KEY".into(),
types::SecretEntry {
shared: "ct".into(),
scoped: BTreeMap::from([(pk2.clone(), "scoped_ct".into())]),
},
);
let mut murk = empty_murk();
let mut scoped = HashMap::new();
scoped.insert(pk2.clone(), "scoped_val".into());
murk.scoped.insert("KEY".into(), scoped);
revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
assert!(vault.secrets["KEY"].scoped.is_empty());
assert!(murk.scoped["KEY"].is_empty());
}
#[test]
fn revoke_recipient_reports_exposed_keys() {
let (_, pk1) = generate_keypair();
let (_, pk2) = generate_keypair();
let mut vault = empty_vault();
vault.recipients = vec![pk1.clone(), pk2.clone()];
vault.schema.insert(
"DB_URL".into(),
types::SchemaEntry {
description: "db".into(),
example: None,
tags: vec![],
..Default::default()
},
);
vault.schema.insert(
"API_KEY".into(),
types::SchemaEntry {
description: "api".into(),
example: None,
tags: vec![],
..Default::default()
},
);
vault.secrets.insert(
"DB_URL".into(),
types::SecretEntry {
shared: "ct".into(),
scoped: BTreeMap::from([(pk2.clone(), "scoped_db".into())]),
},
);
vault.secrets.insert(
"API_KEY".into(),
types::SecretEntry {
shared: "ct2".into(),
scoped: BTreeMap::from([(pk2.clone(), "scoped_api".into())]),
},
);
let mut murk = empty_murk();
murk.scoped
.insert("DB_URL".into(), HashMap::from([(pk2.clone(), "v".into())]));
murk.scoped.insert(
"API_KEY".into(),
HashMap::from([(pk2.clone(), "v2".into())]),
);
let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
let mut keys = result.exposed_keys.clone();
keys.sort();
assert_eq!(keys, vec!["API_KEY", "DB_URL"]);
assert!(vault.secrets["DB_URL"].scoped.is_empty());
assert!(vault.secrets["API_KEY"].scoped.is_empty());
}
#[test]
fn list_recipients_with_meta() {
let (secret, pubkey) = generate_keypair();
let (_, pk2) = generate_keypair();
let recipient = make_recipient(&pubkey);
let mut names = std::collections::HashMap::new();
names.insert(pubkey.clone(), "Alice".to_string());
names.insert(pk2.clone(), "Bob".to_string());
let meta = types::Meta {
recipients: names,
mac: String::new(),
mac_key: None,
github_pins: HashMap::new(),
};
let meta_json = serde_json::to_vec(&meta).unwrap();
let r2 = make_recipient(&pk2);
let meta_enc = crate::encrypt_value(&meta_json, &[recipient, r2]).unwrap();
let mut vault = empty_vault();
vault.recipients = vec![pubkey.clone(), pk2.clone()];
vault.meta = meta_enc;
let entries = list_recipients(&vault, Some(&secret));
assert_eq!(entries.len(), 2);
let me = entries.iter().find(|e| e.pubkey == pubkey).unwrap();
assert!(me.is_self);
assert_eq!(me.display_name.as_deref(), Some("Alice"));
let other = entries.iter().find(|e| e.pubkey == pk2).unwrap();
assert!(!other.is_self);
assert_eq!(other.display_name.as_deref(), Some("Bob"));
}
#[test]
fn list_recipients_without_key() {
let (_, pubkey) = generate_keypair();
let mut vault = empty_vault();
vault.recipients = vec![pubkey.clone()];
let entries = list_recipients(&vault, None);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].pubkey, pubkey);
assert!(entries[0].display_name.is_none());
assert!(!entries[0].is_self);
}
#[test]
fn list_recipients_wrong_key() {
let (_, pubkey) = generate_keypair();
let recipient = make_recipient(&pubkey);
let (wrong_secret, _) = generate_keypair();
let meta = types::Meta {
recipients: std::collections::HashMap::from([(pubkey.clone(), "Alice".into())]),
mac: String::new(),
mac_key: None,
github_pins: HashMap::new(),
};
let meta_json = serde_json::to_vec(&meta).unwrap();
let meta_enc = crate::encrypt_value(&meta_json, &[recipient]).unwrap();
let mut vault = empty_vault();
vault.recipients = vec![pubkey.clone()];
vault.meta = meta_enc;
let entries = list_recipients(&vault, Some(&wrong_secret));
assert_eq!(entries.len(), 1);
assert!(entries[0].display_name.is_none());
assert!(!entries[0].is_self);
}
#[test]
fn list_recipients_empty_vault() {
let vault = empty_vault();
let entries = list_recipients(&vault, None);
assert!(entries.is_empty());
}
#[test]
fn revoke_recipient_no_scoped() {
let (_, pk1) = generate_keypair();
let (_, pk2) = generate_keypair();
let mut vault = empty_vault();
vault.recipients = vec![pk1.clone(), pk2.clone()];
let mut murk = empty_murk();
murk.recipients.insert(pk2.clone(), "bob".into());
let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
assert_eq!(result.display_name.as_deref(), Some("bob"));
assert!(!vault.recipients.contains(&pk2));
}
#[test]
fn revoke_by_name_rejects_ambiguous_match() {
let (_, pk_owner) = generate_keypair();
let (_, pk_ssh1) = generate_keypair();
let (_, pk_ssh2) = generate_keypair();
let mut vault = empty_vault();
vault.recipients = vec![pk_owner.clone(), pk_ssh1.clone(), pk_ssh2.clone()];
let mut murk = empty_murk();
murk.recipients
.insert(pk_ssh1.clone(), "alice@github".into());
murk.recipients
.insert(pk_ssh2.clone(), "alice@github".into());
let result = revoke_recipient(&mut vault, &mut murk, "alice@github");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("ambiguous name"));
}
#[test]
fn truncate_age_key() {
let pk = "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p";
let truncated = truncate_pubkey(pk);
assert!(truncated.len() < pk.len());
assert!(truncated.starts_with("age1ql3z"));
assert!(truncated.contains('…'));
}
#[test]
fn truncate_ssh_key() {
let pk = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGVsample";
let truncated = truncate_pubkey(pk);
assert!(!truncated.starts_with("ssh-ed25519"));
assert!(truncated.contains('…'));
}
#[test]
fn truncate_short_key_unchanged() {
assert_eq!(truncate_pubkey("age1short"), "age1short");
}
#[test]
fn key_type_labels() {
assert_eq!(key_type_label("age1abc"), "age");
assert_eq!(key_type_label("ssh-ed25519 AAAA"), "ed25519");
assert_eq!(key_type_label("ssh-rsa AAAA"), "rsa");
}
#[test]
fn format_recipients_no_names() {
let entries = vec![
RecipientEntry {
pubkey: "age1abc".into(),
display_name: None,
is_self: false,
},
RecipientEntry {
pubkey: "age1xyz".into(),
display_name: None,
is_self: false,
},
];
let lines = format_recipient_lines(&entries);
assert_eq!(lines, vec!["age1abc", "age1xyz"]);
}
#[test]
fn format_recipients_with_names() {
let entries = vec![
RecipientEntry {
pubkey: "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p".into(),
display_name: Some("alice".into()),
is_self: true,
},
RecipientEntry {
pubkey: "age1xyz7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p".into(),
display_name: Some("bob".into()),
is_self: false,
},
];
let lines = format_recipient_lines(&entries);
assert_eq!(lines.len(), 2);
assert!(lines[0].starts_with("◆"));
assert!(lines[0].contains("alice"));
assert!(lines[1].starts_with(" "));
assert!(lines[1].contains("bob"));
}
#[test]
fn format_recipients_groups_multi_key() {
let entries = vec![
RecipientEntry {
pubkey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGVkey1sample".into(),
display_name: Some("alice@github".into()),
is_self: false,
},
RecipientEntry {
pubkey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGVkey2sample".into(),
display_name: Some("alice@github".into()),
is_self: false,
},
];
let lines = format_recipient_lines(&entries);
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("(2 keys)"));
}
}