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 },
341 );
342 vault.secrets.insert(
343 "KEY".into(),
344 types::SecretEntry {
345 shared: "ciphertext".into(),
346 scoped: std::collections::BTreeMap::new(),
347 },
348 );
349 let mut murk = empty_murk();
350 murk.recipients.insert(pk2.clone(), "bob".into());
351
352 let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
353 assert_eq!(result.display_name.as_deref(), Some("bob"));
354 assert!(!vault.recipients.contains(&pk2));
355 assert!(vault.recipients.contains(&pk1));
356 assert_eq!(result.exposed_keys, vec!["KEY"]);
357 }
358
359 #[test]
360 fn revoke_recipient_by_name() {
361 let (_, pk1) = generate_keypair();
362 let (_, pk2) = generate_keypair();
363 let mut vault = empty_vault();
364 vault.recipients = vec![pk1.clone(), pk2.clone()];
365 let mut murk = empty_murk();
366 murk.recipients.insert(pk2.clone(), "bob".into());
367
368 let result = revoke_recipient(&mut vault, &mut murk, "bob").unwrap();
369 assert_eq!(result.display_name.as_deref(), Some("bob"));
370 assert!(!vault.recipients.contains(&pk2));
371 }
372
373 #[test]
374 fn revoke_recipient_last_fails() {
375 let (_, pk) = generate_keypair();
376 let mut vault = empty_vault();
377 vault.recipients = vec![pk.clone()];
378 let mut murk = empty_murk();
379
380 let result = revoke_recipient(&mut vault, &mut murk, &pk);
381 assert!(result.is_err());
382 assert!(
383 result
384 .unwrap_err()
385 .to_string()
386 .contains("cannot revoke last recipient")
387 );
388 }
389
390 #[test]
391 fn revoke_recipient_unknown_fails() {
392 let (_, pk) = generate_keypair();
393 let mut vault = empty_vault();
394 vault.recipients = vec![pk.clone()];
395 let mut murk = empty_murk();
396
397 let result = revoke_recipient(&mut vault, &mut murk, "nobody");
398 assert!(result.is_err());
399 assert!(
400 result
401 .unwrap_err()
402 .to_string()
403 .contains("recipient not found")
404 );
405 }
406
407 #[test]
408 fn revoke_recipient_removes_scoped() {
409 let (_, pk1) = generate_keypair();
410 let (_, pk2) = generate_keypair();
411 let mut vault = empty_vault();
412 vault.recipients = vec![pk1.clone(), pk2.clone()];
413 vault.secrets.insert(
414 "KEY".into(),
415 types::SecretEntry {
416 shared: "ct".into(),
417 scoped: BTreeMap::from([(pk2.clone(), "scoped_ct".into())]),
418 },
419 );
420 let mut murk = empty_murk();
421 let mut scoped = HashMap::new();
422 scoped.insert(pk2.clone(), "scoped_val".into());
423 murk.scoped.insert("KEY".into(), scoped);
424
425 revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
426
427 assert!(vault.secrets["KEY"].scoped.is_empty());
428 assert!(murk.scoped["KEY"].is_empty());
429 }
430
431 #[test]
432 fn revoke_recipient_reports_exposed_keys() {
433 let (_, pk1) = generate_keypair();
434 let (_, pk2) = generate_keypair();
435 let mut vault = empty_vault();
436 vault.recipients = vec![pk1.clone(), pk2.clone()];
437 vault.schema.insert(
439 "DB_URL".into(),
440 types::SchemaEntry {
441 description: "db".into(),
442 example: None,
443 tags: vec![],
444 },
445 );
446 vault.schema.insert(
447 "API_KEY".into(),
448 types::SchemaEntry {
449 description: "api".into(),
450 example: None,
451 tags: vec![],
452 },
453 );
454 vault.secrets.insert(
455 "DB_URL".into(),
456 types::SecretEntry {
457 shared: "ct".into(),
458 scoped: BTreeMap::from([(pk2.clone(), "scoped_db".into())]),
459 },
460 );
461 vault.secrets.insert(
462 "API_KEY".into(),
463 types::SecretEntry {
464 shared: "ct2".into(),
465 scoped: BTreeMap::from([(pk2.clone(), "scoped_api".into())]),
466 },
467 );
468 let mut murk = empty_murk();
469 murk.scoped
470 .insert("DB_URL".into(), HashMap::from([(pk2.clone(), "v".into())]));
471 murk.scoped.insert(
472 "API_KEY".into(),
473 HashMap::from([(pk2.clone(), "v2".into())]),
474 );
475
476 let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
477 let mut keys = result.exposed_keys.clone();
478 keys.sort();
479 assert_eq!(keys, vec!["API_KEY", "DB_URL"]);
480 assert!(vault.secrets["DB_URL"].scoped.is_empty());
481 assert!(vault.secrets["API_KEY"].scoped.is_empty());
482 }
483
484 #[test]
487 fn list_recipients_with_meta() {
488 let (secret, pubkey) = generate_keypair();
489 let (_, pk2) = generate_keypair();
490 let recipient = make_recipient(&pubkey);
491
492 let mut names = std::collections::HashMap::new();
493 names.insert(pubkey.clone(), "Alice".to_string());
494 names.insert(pk2.clone(), "Bob".to_string());
495 let meta = types::Meta {
496 recipients: names,
497 mac: String::new(),
498 hmac_key: None,
499 };
500 let meta_json = serde_json::to_vec(&meta).unwrap();
501 let r2 = make_recipient(&pk2);
502 let meta_enc = crate::encrypt_value(&meta_json, &[recipient, r2]).unwrap();
503
504 let mut vault = empty_vault();
505 vault.recipients = vec![pubkey.clone(), pk2.clone()];
506 vault.meta = meta_enc;
507
508 let entries = list_recipients(&vault, Some(&secret));
509 assert_eq!(entries.len(), 2);
510 let me = entries.iter().find(|e| e.pubkey == pubkey).unwrap();
511 assert!(me.is_self);
512 assert_eq!(me.display_name.as_deref(), Some("Alice"));
513 let other = entries.iter().find(|e| e.pubkey == pk2).unwrap();
514 assert!(!other.is_self);
515 assert_eq!(other.display_name.as_deref(), Some("Bob"));
516 }
517
518 #[test]
519 fn list_recipients_without_key() {
520 let (_, pubkey) = generate_keypair();
521 let mut vault = empty_vault();
522 vault.recipients = vec![pubkey.clone()];
523
524 let entries = list_recipients(&vault, None);
525 assert_eq!(entries.len(), 1);
526 assert_eq!(entries[0].pubkey, pubkey);
527 assert!(entries[0].display_name.is_none());
528 assert!(!entries[0].is_self);
529 }
530
531 #[test]
532 fn list_recipients_wrong_key() {
533 let (_, pubkey) = generate_keypair();
534 let recipient = make_recipient(&pubkey);
535 let (wrong_secret, _) = generate_keypair();
536
537 let meta = types::Meta {
538 recipients: std::collections::HashMap::from([(pubkey.clone(), "Alice".into())]),
539 mac: String::new(),
540 hmac_key: None,
541 };
542 let meta_json = serde_json::to_vec(&meta).unwrap();
543 let meta_enc = crate::encrypt_value(&meta_json, &[recipient]).unwrap();
544
545 let mut vault = empty_vault();
546 vault.recipients = vec![pubkey.clone()];
547 vault.meta = meta_enc;
548
549 let entries = list_recipients(&vault, Some(&wrong_secret));
550 assert_eq!(entries.len(), 1);
551 assert!(entries[0].display_name.is_none());
552 assert!(!entries[0].is_self);
553 }
554
555 #[test]
556 fn list_recipients_empty_vault() {
557 let vault = empty_vault();
558 let entries = list_recipients(&vault, None);
559 assert!(entries.is_empty());
560 }
561
562 #[test]
563 fn revoke_recipient_no_scoped() {
564 let (_, pk1) = generate_keypair();
565 let (_, pk2) = generate_keypair();
566 let mut vault = empty_vault();
567 vault.recipients = vec![pk1.clone(), pk2.clone()];
568 let mut murk = empty_murk();
569 murk.recipients.insert(pk2.clone(), "bob".into());
570
571 let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
572 assert_eq!(result.display_name.as_deref(), Some("bob"));
573 assert!(!vault.recipients.contains(&pk2));
574 }
575
576 #[test]
577 fn revoke_by_name_rejects_ambiguous_match() {
578 let (_, pk_owner) = generate_keypair();
579 let (_, pk_ssh1) = generate_keypair();
580 let (_, pk_ssh2) = generate_keypair();
581 let mut vault = empty_vault();
582 vault.recipients = vec![pk_owner.clone(), pk_ssh1.clone(), pk_ssh2.clone()];
583 let mut murk = empty_murk();
584 murk.recipients
585 .insert(pk_ssh1.clone(), "alice@github".into());
586 murk.recipients
587 .insert(pk_ssh2.clone(), "alice@github".into());
588
589 let result = revoke_recipient(&mut vault, &mut murk, "alice@github");
590 assert!(result.is_err());
591 assert!(result.unwrap_err().to_string().contains("ambiguous name"));
592 }
593
594 #[test]
597 fn truncate_age_key() {
598 let pk = "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p";
599 let truncated = truncate_pubkey(pk);
600 assert!(truncated.len() < pk.len());
601 assert!(truncated.starts_with("age1ql3z"));
602 assert!(truncated.contains('…'));
603 }
604
605 #[test]
606 fn truncate_ssh_key() {
607 let pk = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGVsample";
608 let truncated = truncate_pubkey(pk);
609 assert!(!truncated.starts_with("ssh-ed25519"));
610 assert!(truncated.contains('…'));
611 }
612
613 #[test]
614 fn truncate_short_key_unchanged() {
615 assert_eq!(truncate_pubkey("age1short"), "age1short");
616 }
617
618 #[test]
619 fn key_type_labels() {
620 assert_eq!(key_type_label("age1abc"), "age");
621 assert_eq!(key_type_label("ssh-ed25519 AAAA"), "ed25519");
622 assert_eq!(key_type_label("ssh-rsa AAAA"), "rsa");
623 }
624
625 #[test]
626 fn format_recipients_no_names() {
627 let entries = vec![
628 RecipientEntry {
629 pubkey: "age1abc".into(),
630 display_name: None,
631 is_self: false,
632 },
633 RecipientEntry {
634 pubkey: "age1xyz".into(),
635 display_name: None,
636 is_self: false,
637 },
638 ];
639 let lines = format_recipient_lines(&entries);
640 assert_eq!(lines, vec!["age1abc", "age1xyz"]);
641 }
642
643 #[test]
644 fn format_recipients_with_names() {
645 let entries = vec![
646 RecipientEntry {
647 pubkey: "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p".into(),
648 display_name: Some("alice".into()),
649 is_self: true,
650 },
651 RecipientEntry {
652 pubkey: "age1xyz7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p".into(),
653 display_name: Some("bob".into()),
654 is_self: false,
655 },
656 ];
657 let lines = format_recipient_lines(&entries);
658 assert_eq!(lines.len(), 2);
659 assert!(lines[0].starts_with("◆"));
660 assert!(lines[0].contains("alice"));
661 assert!(lines[1].starts_with(" "));
662 assert!(lines[1].contains("bob"));
663 }
664
665 #[test]
666 fn format_recipients_groups_multi_key() {
667 let entries = vec![
668 RecipientEntry {
669 pubkey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGVkey1sample".into(),
670 display_name: Some("alice@github".into()),
671 is_self: false,
672 },
673 RecipientEntry {
674 pubkey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGVkey2sample".into(),
675 display_name: Some("alice@github".into()),
676 is_self: false,
677 },
678 ];
679 let lines = format_recipient_lines(&entries);
680 assert_eq!(lines.len(), 1);
681 assert!(lines[0].contains("(2 keys)"));
682 }
683}