1use crate::{crypto, types};
4
5const MAX_RECIPIENTS: usize = 100;
7
8#[derive(Debug)]
10pub struct RecipientEntry {
11 pub pubkey: String,
12 pub display_name: Option<String>,
13 pub is_self: bool,
14}
15
16pub 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
48pub 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#[derive(Debug)]
86pub struct RevokeResult {
87 pub display_name: Option<String>,
89 pub exposed_keys: Vec<String>,
91}
92
93pub 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 revoked_groups: std::collections::BTreeSet<String> = murk
136 .groups
137 .iter()
138 .filter(|(_, members)| members.iter().any(|pk| pubkeys.contains(pk)))
139 .map(|(name, _)| name.clone())
140 .collect();
141
142 let mut display_name = None;
143 for pubkey in &pubkeys {
144 vault.recipients.retain(|pk| pk != pubkey);
145
146 if let Some(name) = murk.recipients.remove(pubkey) {
147 display_name = Some(name);
148 }
149
150 for scoped_map in murk.private.values_mut() {
152 scoped_map.remove(pubkey);
153 }
154 for entry in vault.secrets.values_mut() {
155 entry.private.remove(pubkey);
156 }
157
158 for members in murk.groups.values_mut() {
161 members.retain(|pk| pk != pubkey);
162 }
163 }
164
165 murk.grants
169 .retain(|_, grant| !pubkeys.contains(&grant.pubkey));
170
171 let exposed_keys: Vec<String> = vault
175 .secrets
176 .iter()
177 .filter(|(_, entry)| {
178 !entry.shared.is_empty()
179 || pubkeys.iter().any(|pk| entry.private.contains_key(pk))
180 || entry.grouped.keys().any(|g| revoked_groups.contains(g))
181 })
182 .map(|(key, _)| key.clone())
183 .collect();
184
185 Ok(RevokeResult {
186 display_name,
187 exposed_keys,
188 })
189}
190
191pub fn truncate_pubkey(pk: &str) -> String {
193 if let Some(key_data) = pk.strip_prefix("ssh-ed25519 ") {
194 return truncate_raw(key_data);
195 }
196 if let Some(key_data) = pk.strip_prefix("ssh-rsa ") {
197 return truncate_raw(key_data);
198 }
199 truncate_raw(pk)
200}
201
202fn truncate_raw(s: &str) -> String {
203 if s.len() <= 13 {
204 return s.to_string();
205 }
206 let start: String = s.chars().take(8).collect();
207 let end: String = s
208 .chars()
209 .rev()
210 .take(4)
211 .collect::<Vec<_>>()
212 .into_iter()
213 .rev()
214 .collect();
215 format!("{start}…{end}")
216}
217
218pub fn key_type_label(pk: &str) -> &'static str {
220 if pk.starts_with("ssh-ed25519 ") {
221 "ed25519"
222 } else if pk.starts_with("ssh-rsa ") {
223 "rsa"
224 } else {
225 "age"
226 }
227}
228
229pub struct RecipientGroup<'a> {
231 pub name: Option<&'a str>,
232 pub entries: Vec<&'a RecipientEntry>,
233 pub is_self: bool,
234}
235
236pub fn format_recipient_lines(entries: &[RecipientEntry]) -> Vec<String> {
239 let has_names = entries.iter().any(|e| e.display_name.is_some());
240 if !has_names {
241 return entries.iter().map(|e| e.pubkey.clone()).collect();
242 }
243
244 let groups = group_recipients(entries);
245
246 let name_width = groups
247 .iter()
248 .map(|g| g.name.map_or(0, str::len))
249 .max()
250 .unwrap_or(0);
251
252 groups
253 .iter()
254 .map(|g| {
255 let marker = if g.is_self { "◆" } else { " " };
256 let label = g.name.unwrap_or("");
257 let label_padded = format!("{label:<name_width$}");
258 let key_type = key_type_label(&g.entries[0].pubkey);
259 let key_info = if g.entries.len() == 1 {
260 truncate_pubkey(&g.entries[0].pubkey)
261 } else {
262 format!("({} keys)", g.entries.len())
263 };
264 format!("{marker} {label_padded} {key_info} {key_type}")
265 })
266 .collect()
267}
268
269fn group_recipients(entries: &[RecipientEntry]) -> Vec<RecipientGroup<'_>> {
270 let mut groups: Vec<RecipientGroup<'_>> = Vec::new();
271 for entry in entries {
272 let name = entry.display_name.as_deref();
273 if let Some(group) = groups.iter_mut().find(|g| g.name == name && name.is_some()) {
274 group.entries.push(entry);
275 if entry.is_self {
276 group.is_self = true;
277 }
278 } else {
279 groups.push(RecipientGroup {
280 name,
281 entries: vec![entry],
282 is_self: entry.is_self,
283 });
284 }
285 }
286 groups
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use crate::testutil::*;
293 use crate::types;
294 use std::collections::{BTreeMap, HashMap};
295
296 #[test]
297 fn authorize_recipient_success() {
298 let (_, pubkey) = generate_keypair();
299 let mut vault = empty_vault();
300 let mut murk = empty_murk();
301
302 let result = authorize_recipient(&mut vault, &mut murk, &pubkey, Some("alice"));
303 assert!(result.is_ok());
304 assert!(vault.recipients.contains(&pubkey));
305 assert_eq!(murk.recipients[&pubkey], "alice");
306 }
307
308 #[test]
309 fn authorize_recipient_no_name() {
310 let (_, pubkey) = generate_keypair();
311 let mut vault = empty_vault();
312 let mut murk = empty_murk();
313
314 authorize_recipient(&mut vault, &mut murk, &pubkey, None).unwrap();
315 assert!(vault.recipients.contains(&pubkey));
316 assert!(!murk.recipients.contains_key(&pubkey));
317 }
318
319 #[test]
320 fn authorize_recipient_duplicate_fails() {
321 let (_, pubkey) = generate_keypair();
322 let mut vault = empty_vault();
323 vault.recipients.push(pubkey.clone());
324 let mut murk = empty_murk();
325
326 let result = authorize_recipient(&mut vault, &mut murk, &pubkey, None);
327 assert!(result.is_err());
328 assert!(
329 result
330 .unwrap_err()
331 .to_string()
332 .contains("already a recipient")
333 );
334 }
335
336 #[test]
337 fn authorize_recipient_invalid_key_fails() {
338 let mut vault = empty_vault();
339 let mut murk = empty_murk();
340
341 let result = authorize_recipient(&mut vault, &mut murk, "not-a-valid-key", None);
342 assert!(result.is_err());
343 assert!(
344 result
345 .unwrap_err()
346 .to_string()
347 .contains("invalid public key")
348 );
349 }
350
351 #[test]
352 fn revoke_recipient_by_pubkey() {
353 let (_, pk1) = generate_keypair();
354 let (_, pk2) = generate_keypair();
355 let mut vault = empty_vault();
356 vault.recipients = vec![pk1.clone(), pk2.clone()];
357 vault.schema.insert(
358 "KEY".into(),
359 types::SchemaEntry {
360 description: String::new(),
361 example: None,
362 tags: vec![],
363 ..Default::default()
364 },
365 );
366 vault.secrets.insert(
367 "KEY".into(),
368 types::SecretEntry {
369 shared: "ciphertext".into(),
370 private: std::collections::BTreeMap::new(),
371 grouped: std::collections::BTreeMap::default(),
372 },
373 );
374 let mut murk = empty_murk();
375 murk.recipients.insert(pk2.clone(), "bob".into());
376
377 let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
378 assert_eq!(result.display_name.as_deref(), Some("bob"));
379 assert!(!vault.recipients.contains(&pk2));
380 assert!(vault.recipients.contains(&pk1));
381 assert_eq!(result.exposed_keys, vec!["KEY"]);
382 }
383
384 #[test]
385 fn revoke_recipient_by_name() {
386 let (_, pk1) = generate_keypair();
387 let (_, pk2) = generate_keypair();
388 let mut vault = empty_vault();
389 vault.recipients = vec![pk1.clone(), pk2.clone()];
390 let mut murk = empty_murk();
391 murk.recipients.insert(pk2.clone(), "bob".into());
392
393 let result = revoke_recipient(&mut vault, &mut murk, "bob").unwrap();
394 assert_eq!(result.display_name.as_deref(), Some("bob"));
395 assert!(!vault.recipients.contains(&pk2));
396 }
397
398 #[test]
399 fn revoke_recipient_last_fails() {
400 let (_, pk) = generate_keypair();
401 let mut vault = empty_vault();
402 vault.recipients = vec![pk.clone()];
403 let mut murk = empty_murk();
404
405 let result = revoke_recipient(&mut vault, &mut murk, &pk);
406 assert!(result.is_err());
407 assert!(
408 result
409 .unwrap_err()
410 .to_string()
411 .contains("cannot revoke last recipient")
412 );
413 }
414
415 #[test]
416 fn revoke_recipient_unknown_fails() {
417 let (_, pk) = generate_keypair();
418 let mut vault = empty_vault();
419 vault.recipients = vec![pk.clone()];
420 let mut murk = empty_murk();
421
422 let result = revoke_recipient(&mut vault, &mut murk, "nobody");
423 assert!(result.is_err());
424 assert!(
425 result
426 .unwrap_err()
427 .to_string()
428 .contains("recipient not found")
429 );
430 }
431
432 #[test]
433 fn revoke_recipient_removes_scoped() {
434 let (_, pk1) = generate_keypair();
435 let (_, pk2) = generate_keypair();
436 let mut vault = empty_vault();
437 vault.recipients = vec![pk1.clone(), pk2.clone()];
438 vault.secrets.insert(
439 "KEY".into(),
440 types::SecretEntry {
441 shared: "ct".into(),
442 private: BTreeMap::from([(pk2.clone(), "scoped_ct".into())]),
443 grouped: std::collections::BTreeMap::default(),
444 },
445 );
446 let mut murk = empty_murk();
447 let mut scoped = HashMap::new();
448 scoped.insert(pk2.clone(), secret("scoped_val"));
449 murk.private.insert("KEY".into(), scoped);
450
451 revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
452
453 assert!(vault.secrets["KEY"].private.is_empty());
454 assert!(murk.private["KEY"].is_empty());
455 }
456
457 #[test]
458 fn revoke_recipient_reports_exposed_keys() {
459 let (_, pk1) = generate_keypair();
460 let (_, pk2) = generate_keypair();
461 let mut vault = empty_vault();
462 vault.recipients = vec![pk1.clone(), pk2.clone()];
463 vault.schema.insert(
465 "DB_URL".into(),
466 types::SchemaEntry {
467 description: "db".into(),
468 example: None,
469 tags: vec![],
470 ..Default::default()
471 },
472 );
473 vault.schema.insert(
474 "API_KEY".into(),
475 types::SchemaEntry {
476 description: "api".into(),
477 example: None,
478 tags: vec![],
479 ..Default::default()
480 },
481 );
482 vault.secrets.insert(
483 "DB_URL".into(),
484 types::SecretEntry {
485 shared: "ct".into(),
486 private: BTreeMap::from([(pk2.clone(), "scoped_db".into())]),
487 grouped: std::collections::BTreeMap::default(),
488 },
489 );
490 vault.secrets.insert(
491 "API_KEY".into(),
492 types::SecretEntry {
493 shared: "ct2".into(),
494 private: BTreeMap::from([(pk2.clone(), "scoped_api".into())]),
495 grouped: std::collections::BTreeMap::default(),
496 },
497 );
498 let mut murk = empty_murk();
499 murk.private
500 .insert("DB_URL".into(), HashMap::from([(pk2.clone(), secret("v"))]));
501 murk.private.insert(
502 "API_KEY".into(),
503 HashMap::from([(pk2.clone(), secret("v2"))]),
504 );
505
506 let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
507 let mut keys = result.exposed_keys.clone();
508 keys.sort();
509 assert_eq!(keys, vec!["API_KEY", "DB_URL"]);
510 assert!(vault.secrets["DB_URL"].private.is_empty());
511 assert!(vault.secrets["API_KEY"].private.is_empty());
512 }
513
514 #[test]
517 fn list_recipients_with_meta() {
518 let (secret, pubkey) = generate_keypair();
519 let (_, pk2) = generate_keypair();
520 let recipient = make_recipient(&pubkey);
521
522 let mut names = std::collections::HashMap::new();
523 names.insert(pubkey.clone(), "Alice".to_string());
524 names.insert(pk2.clone(), "Bob".to_string());
525 let meta = types::Meta {
526 recipients: names,
527 mac: String::new(),
528 mac_key: None,
529 github_pins: HashMap::new(),
530 ..Default::default()
531 };
532 let meta_json = serde_json::to_vec(&meta).unwrap();
533 let r2 = make_recipient(&pk2);
534 let meta_enc = crate::encrypt_value(&meta_json, &[recipient, r2]).unwrap();
535
536 let mut vault = empty_vault();
537 vault.recipients = vec![pubkey.clone(), pk2.clone()];
538 vault.meta = meta_enc;
539
540 let entries = list_recipients(&vault, Some(&secret));
541 assert_eq!(entries.len(), 2);
542 let me = entries.iter().find(|e| e.pubkey == pubkey).unwrap();
543 assert!(me.is_self);
544 assert_eq!(me.display_name.as_deref(), Some("Alice"));
545 let other = entries.iter().find(|e| e.pubkey == pk2).unwrap();
546 assert!(!other.is_self);
547 assert_eq!(other.display_name.as_deref(), Some("Bob"));
548 }
549
550 #[test]
551 fn list_recipients_without_key() {
552 let (_, pubkey) = generate_keypair();
553 let mut vault = empty_vault();
554 vault.recipients = vec![pubkey.clone()];
555
556 let entries = list_recipients(&vault, None);
557 assert_eq!(entries.len(), 1);
558 assert_eq!(entries[0].pubkey, pubkey);
559 assert!(entries[0].display_name.is_none());
560 assert!(!entries[0].is_self);
561 }
562
563 #[test]
564 fn list_recipients_wrong_key() {
565 let (_, pubkey) = generate_keypair();
566 let recipient = make_recipient(&pubkey);
567 let (wrong_secret, _) = generate_keypair();
568
569 let meta = types::Meta {
570 recipients: std::collections::HashMap::from([(pubkey.clone(), "Alice".into())]),
571 mac: String::new(),
572 mac_key: None,
573 github_pins: HashMap::new(),
574 ..Default::default()
575 };
576 let meta_json = serde_json::to_vec(&meta).unwrap();
577 let meta_enc = crate::encrypt_value(&meta_json, &[recipient]).unwrap();
578
579 let mut vault = empty_vault();
580 vault.recipients = vec![pubkey.clone()];
581 vault.meta = meta_enc;
582
583 let entries = list_recipients(&vault, Some(&wrong_secret));
584 assert_eq!(entries.len(), 1);
585 assert!(entries[0].display_name.is_none());
586 assert!(!entries[0].is_self);
587 }
588
589 #[test]
590 fn list_recipients_empty_vault() {
591 let vault = empty_vault();
592 let entries = list_recipients(&vault, None);
593 assert!(entries.is_empty());
594 }
595
596 #[test]
597 fn revoke_recipient_no_scoped() {
598 let (_, pk1) = generate_keypair();
599 let (_, pk2) = generate_keypair();
600 let mut vault = empty_vault();
601 vault.recipients = vec![pk1.clone(), pk2.clone()];
602 let mut murk = empty_murk();
603 murk.recipients.insert(pk2.clone(), "bob".into());
604
605 let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
606 assert_eq!(result.display_name.as_deref(), Some("bob"));
607 assert!(!vault.recipients.contains(&pk2));
608 }
609
610 #[test]
611 fn revoke_by_name_rejects_ambiguous_match() {
612 let (_, pk_owner) = generate_keypair();
613 let (_, pk_ssh1) = generate_keypair();
614 let (_, pk_ssh2) = generate_keypair();
615 let mut vault = empty_vault();
616 vault.recipients = vec![pk_owner.clone(), pk_ssh1.clone(), pk_ssh2.clone()];
617 let mut murk = empty_murk();
618 murk.recipients
619 .insert(pk_ssh1.clone(), "alice@github".into());
620 murk.recipients
621 .insert(pk_ssh2.clone(), "alice@github".into());
622
623 let result = revoke_recipient(&mut vault, &mut murk, "alice@github");
624 assert!(result.is_err());
625 assert!(result.unwrap_err().to_string().contains("ambiguous name"));
626 }
627
628 #[test]
631 fn truncate_age_key() {
632 let pk = "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p";
633 let truncated = truncate_pubkey(pk);
634 assert!(truncated.len() < pk.len());
635 assert!(truncated.starts_with("age1ql3z"));
636 assert!(truncated.contains('…'));
637 }
638
639 #[test]
640 fn truncate_ssh_key() {
641 let pk = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGVsample";
642 let truncated = truncate_pubkey(pk);
643 assert!(!truncated.starts_with("ssh-ed25519"));
644 assert!(truncated.contains('…'));
645 }
646
647 #[test]
648 fn truncate_short_key_unchanged() {
649 assert_eq!(truncate_pubkey("age1short"), "age1short");
650 }
651
652 #[test]
653 fn key_type_labels() {
654 assert_eq!(key_type_label("age1abc"), "age");
655 assert_eq!(key_type_label("ssh-ed25519 AAAA"), "ed25519");
656 assert_eq!(key_type_label("ssh-rsa AAAA"), "rsa");
657 }
658
659 #[test]
660 fn format_recipients_no_names() {
661 let entries = vec![
662 RecipientEntry {
663 pubkey: "age1abc".into(),
664 display_name: None,
665 is_self: false,
666 },
667 RecipientEntry {
668 pubkey: "age1xyz".into(),
669 display_name: None,
670 is_self: false,
671 },
672 ];
673 let lines = format_recipient_lines(&entries);
674 assert_eq!(lines, vec!["age1abc", "age1xyz"]);
675 }
676
677 #[test]
678 fn format_recipients_with_names() {
679 let entries = vec![
680 RecipientEntry {
681 pubkey: "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p".into(),
682 display_name: Some("alice".into()),
683 is_self: true,
684 },
685 RecipientEntry {
686 pubkey: "age1xyz7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p".into(),
687 display_name: Some("bob".into()),
688 is_self: false,
689 },
690 ];
691 let lines = format_recipient_lines(&entries);
692 assert_eq!(lines.len(), 2);
693 assert!(lines[0].starts_with("◆"));
694 assert!(lines[0].contains("alice"));
695 assert!(lines[1].starts_with(' '));
696 assert!(lines[1].contains("bob"));
697 }
698
699 #[test]
700 fn format_recipients_groups_multi_key() {
701 let entries = vec![
702 RecipientEntry {
703 pubkey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGVkey1sample".into(),
704 display_name: Some("alice@github".into()),
705 is_self: false,
706 },
707 RecipientEntry {
708 pubkey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGVkey2sample".into(),
709 display_name: Some("alice@github".into()),
710 is_self: false,
711 },
712 ];
713 let lines = format_recipient_lines(&entries);
714 assert_eq!(lines.len(), 1);
715 assert!(lines[0].contains("(2 keys)"));
716 }
717}