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