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