1use crate::{crypto, types};
4
5#[derive(Debug)]
7pub struct RecipientEntry {
8 pub pubkey: String,
9 pub display_name: Option<String>,
10 pub is_self: bool,
11}
12
13pub fn list_recipients(vault: &types::Vault, secret_key: Option<&str>) -> Vec<RecipientEntry> {
18 let meta_data = secret_key.filter(|k| !k.is_empty()).and_then(|sk| {
19 let identity = crypto::parse_identity(sk).ok()?;
20 let my_pubkey = identity.pubkey_string().ok()?;
21 let meta = crate::decrypt_meta(vault, &identity)?;
22 Some((meta, my_pubkey))
23 });
24
25 vault
26 .recipients
27 .iter()
28 .map(|pk| {
29 let (display_name, is_self) = match &meta_data {
30 Some((meta, my_pubkey)) => {
31 let name = meta.recipients.get(pk).filter(|n| !n.is_empty()).cloned();
32 (name, pk == my_pubkey)
33 }
34 None => (None, false),
35 };
36 RecipientEntry {
37 pubkey: pk.clone(),
38 display_name,
39 is_self,
40 }
41 })
42 .collect()
43}
44
45pub fn authorize_recipient(
47 vault: &mut types::Vault,
48 murk: &mut types::Murk,
49 pubkey: &str,
50 name: Option<&str>,
51) -> Result<(), String> {
52 if crypto::parse_recipient(pubkey).is_err() {
53 return Err(format!("invalid public key: {pubkey}"));
54 }
55
56 if vault.recipients.contains(&pubkey.to_string()) {
57 return Err(format!("{pubkey} is already a recipient"));
58 }
59
60 vault.recipients.push(pubkey.into());
61
62 if let Some(n) = name {
63 murk.recipients.insert(pubkey.into(), n.into());
64 }
65
66 Ok(())
67}
68
69#[derive(Debug)]
71pub struct RevokeResult {
72 pub display_name: Option<String>,
74 pub exposed_keys: Vec<String>,
76}
77
78pub fn revoke_recipient(
84 vault: &mut types::Vault,
85 murk: &mut types::Murk,
86 recipient: &str,
87) -> Result<RevokeResult, String> {
88 let pubkeys: Vec<String> = if vault.recipients.contains(&recipient.to_string()) {
90 vec![recipient.to_string()]
92 } else {
93 let matched: Vec<String> = murk
95 .recipients
96 .iter()
97 .filter(|(_, name)| name.as_str() == recipient)
98 .map(|(pk, _)| pk.clone())
99 .collect();
100 if matched.is_empty() {
101 return Err(format!("recipient not found: {recipient}"));
102 }
103 matched
104 };
105
106 if vault.recipients.len() <= pubkeys.len() {
107 return Err(
108 "cannot revoke last recipient — vault would become permanently inaccessible".into(),
109 );
110 }
111
112 let mut display_name = None;
113 for pubkey in &pubkeys {
114 vault.recipients.retain(|pk| pk != pubkey);
115
116 if let Some(name) = murk.recipients.remove(pubkey) {
117 display_name = Some(name);
118 }
119
120 for scoped_map in murk.scoped.values_mut() {
122 scoped_map.remove(pubkey);
123 }
124 for entry in vault.secrets.values_mut() {
125 entry.scoped.remove(pubkey);
126 }
127 }
128
129 let exposed_keys = vault.schema.keys().cloned().collect();
130
131 Ok(RevokeResult {
132 display_name,
133 exposed_keys,
134 })
135}
136
137pub fn truncate_pubkey(pk: &str) -> String {
139 if let Some(key_data) = pk.strip_prefix("ssh-ed25519 ") {
140 return truncate_raw(key_data);
141 }
142 if let Some(key_data) = pk.strip_prefix("ssh-rsa ") {
143 return truncate_raw(key_data);
144 }
145 truncate_raw(pk)
146}
147
148fn truncate_raw(s: &str) -> String {
149 if s.len() <= 13 {
150 return s.to_string();
151 }
152 let start: String = s.chars().take(8).collect();
153 let end: String = s
154 .chars()
155 .rev()
156 .take(4)
157 .collect::<Vec<_>>()
158 .into_iter()
159 .rev()
160 .collect();
161 format!("{start}…{end}")
162}
163
164pub fn key_type_label(pk: &str) -> &'static str {
166 if pk.starts_with("ssh-ed25519 ") {
167 "ed25519"
168 } else if pk.starts_with("ssh-rsa ") {
169 "rsa"
170 } else {
171 "age"
172 }
173}
174
175pub struct RecipientGroup<'a> {
177 pub name: Option<&'a str>,
178 pub entries: Vec<&'a RecipientEntry>,
179 pub is_self: bool,
180}
181
182pub fn format_recipient_lines(entries: &[RecipientEntry]) -> Vec<String> {
185 let has_names = entries.iter().any(|e| e.display_name.is_some());
186 if !has_names {
187 return entries.iter().map(|e| e.pubkey.clone()).collect();
188 }
189
190 let groups = group_recipients(entries);
191
192 let name_width = groups
193 .iter()
194 .map(|g| g.name.map_or(0, str::len))
195 .max()
196 .unwrap_or(0);
197
198 groups
199 .iter()
200 .map(|g| {
201 let marker = if g.is_self { "◆" } else { " " };
202 let label = g.name.unwrap_or("");
203 let label_padded = format!("{label:<name_width$}");
204 let key_type = key_type_label(&g.entries[0].pubkey);
205 let key_info = if g.entries.len() == 1 {
206 truncate_pubkey(&g.entries[0].pubkey)
207 } else {
208 format!("({} keys)", g.entries.len())
209 };
210 format!("{marker} {label_padded} {key_info} {key_type}")
211 })
212 .collect()
213}
214
215fn group_recipients(entries: &[RecipientEntry]) -> Vec<RecipientGroup<'_>> {
216 let mut groups: Vec<RecipientGroup<'_>> = Vec::new();
217 for entry in entries {
218 let name = entry.display_name.as_deref();
219 if let Some(group) = groups.iter_mut().find(|g| g.name == name && name.is_some()) {
220 group.entries.push(entry);
221 if entry.is_self {
222 group.is_self = true;
223 }
224 } else {
225 groups.push(RecipientGroup {
226 name,
227 entries: vec![entry],
228 is_self: entry.is_self,
229 });
230 }
231 }
232 groups
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use crate::testutil::*;
239 use crate::types;
240 use std::collections::{BTreeMap, HashMap};
241
242 #[test]
243 fn authorize_recipient_success() {
244 let (_, pubkey) = generate_keypair();
245 let mut vault = empty_vault();
246 let mut murk = empty_murk();
247
248 let result = authorize_recipient(&mut vault, &mut murk, &pubkey, Some("alice"));
249 assert!(result.is_ok());
250 assert!(vault.recipients.contains(&pubkey));
251 assert_eq!(murk.recipients[&pubkey], "alice");
252 }
253
254 #[test]
255 fn authorize_recipient_no_name() {
256 let (_, pubkey) = generate_keypair();
257 let mut vault = empty_vault();
258 let mut murk = empty_murk();
259
260 authorize_recipient(&mut vault, &mut murk, &pubkey, None).unwrap();
261 assert!(vault.recipients.contains(&pubkey));
262 assert!(!murk.recipients.contains_key(&pubkey));
263 }
264
265 #[test]
266 fn authorize_recipient_duplicate_fails() {
267 let (_, pubkey) = generate_keypair();
268 let mut vault = empty_vault();
269 vault.recipients.push(pubkey.clone());
270 let mut murk = empty_murk();
271
272 let result = authorize_recipient(&mut vault, &mut murk, &pubkey, None);
273 assert!(result.is_err());
274 assert!(result.unwrap_err().contains("already a recipient"));
275 }
276
277 #[test]
278 fn authorize_recipient_invalid_key_fails() {
279 let mut vault = empty_vault();
280 let mut murk = empty_murk();
281
282 let result = authorize_recipient(&mut vault, &mut murk, "not-a-valid-key", None);
283 assert!(result.is_err());
284 assert!(result.unwrap_err().contains("invalid public key"));
285 }
286
287 #[test]
288 fn revoke_recipient_by_pubkey() {
289 let (_, pk1) = generate_keypair();
290 let (_, pk2) = generate_keypair();
291 let mut vault = empty_vault();
292 vault.recipients = vec![pk1.clone(), pk2.clone()];
293 vault.schema.insert(
294 "KEY".into(),
295 types::SchemaEntry {
296 description: String::new(),
297 example: None,
298 tags: vec![],
299 },
300 );
301 let mut murk = empty_murk();
302 murk.recipients.insert(pk2.clone(), "bob".into());
303
304 let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
305 assert_eq!(result.display_name.as_deref(), Some("bob"));
306 assert!(!vault.recipients.contains(&pk2));
307 assert!(vault.recipients.contains(&pk1));
308 assert_eq!(result.exposed_keys, vec!["KEY"]);
309 }
310
311 #[test]
312 fn revoke_recipient_by_name() {
313 let (_, pk1) = generate_keypair();
314 let (_, pk2) = generate_keypair();
315 let mut vault = empty_vault();
316 vault.recipients = vec![pk1.clone(), pk2.clone()];
317 let mut murk = empty_murk();
318 murk.recipients.insert(pk2.clone(), "bob".into());
319
320 let result = revoke_recipient(&mut vault, &mut murk, "bob").unwrap();
321 assert_eq!(result.display_name.as_deref(), Some("bob"));
322 assert!(!vault.recipients.contains(&pk2));
323 }
324
325 #[test]
326 fn revoke_recipient_last_fails() {
327 let (_, pk) = generate_keypair();
328 let mut vault = empty_vault();
329 vault.recipients = vec![pk.clone()];
330 let mut murk = empty_murk();
331
332 let result = revoke_recipient(&mut vault, &mut murk, &pk);
333 assert!(result.is_err());
334 assert!(result.unwrap_err().contains("cannot revoke last recipient"));
335 }
336
337 #[test]
338 fn revoke_recipient_unknown_fails() {
339 let (_, pk) = generate_keypair();
340 let mut vault = empty_vault();
341 vault.recipients = vec![pk.clone()];
342 let mut murk = empty_murk();
343
344 let result = revoke_recipient(&mut vault, &mut murk, "nobody");
345 assert!(result.is_err());
346 assert!(result.unwrap_err().contains("recipient not found"));
347 }
348
349 #[test]
350 fn revoke_recipient_removes_scoped() {
351 let (_, pk1) = generate_keypair();
352 let (_, pk2) = generate_keypair();
353 let mut vault = empty_vault();
354 vault.recipients = vec![pk1.clone(), pk2.clone()];
355 vault.secrets.insert(
356 "KEY".into(),
357 types::SecretEntry {
358 shared: "ct".into(),
359 scoped: BTreeMap::from([(pk2.clone(), "scoped_ct".into())]),
360 },
361 );
362 let mut murk = empty_murk();
363 let mut scoped = HashMap::new();
364 scoped.insert(pk2.clone(), "scoped_val".into());
365 murk.scoped.insert("KEY".into(), scoped);
366
367 revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
368
369 assert!(vault.secrets["KEY"].scoped.is_empty());
370 assert!(murk.scoped["KEY"].is_empty());
371 }
372
373 #[test]
374 fn revoke_recipient_reports_exposed_keys() {
375 let (_, pk1) = generate_keypair();
376 let (_, pk2) = generate_keypair();
377 let mut vault = empty_vault();
378 vault.recipients = vec![pk1.clone(), pk2.clone()];
379 vault.schema.insert(
381 "DB_URL".into(),
382 types::SchemaEntry {
383 description: "db".into(),
384 example: None,
385 tags: vec![],
386 },
387 );
388 vault.schema.insert(
389 "API_KEY".into(),
390 types::SchemaEntry {
391 description: "api".into(),
392 example: None,
393 tags: vec![],
394 },
395 );
396 vault.secrets.insert(
397 "DB_URL".into(),
398 types::SecretEntry {
399 shared: "ct".into(),
400 scoped: BTreeMap::from([(pk2.clone(), "scoped_db".into())]),
401 },
402 );
403 vault.secrets.insert(
404 "API_KEY".into(),
405 types::SecretEntry {
406 shared: "ct2".into(),
407 scoped: BTreeMap::from([(pk2.clone(), "scoped_api".into())]),
408 },
409 );
410 let mut murk = empty_murk();
411 murk.scoped
412 .insert("DB_URL".into(), HashMap::from([(pk2.clone(), "v".into())]));
413 murk.scoped.insert(
414 "API_KEY".into(),
415 HashMap::from([(pk2.clone(), "v2".into())]),
416 );
417
418 let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
419 let mut keys = result.exposed_keys.clone();
420 keys.sort();
421 assert_eq!(keys, vec!["API_KEY", "DB_URL"]);
422 assert!(vault.secrets["DB_URL"].scoped.is_empty());
423 assert!(vault.secrets["API_KEY"].scoped.is_empty());
424 }
425
426 #[test]
429 fn list_recipients_with_meta() {
430 let (secret, pubkey) = generate_keypair();
431 let (_, pk2) = generate_keypair();
432 let recipient = make_recipient(&pubkey);
433
434 let mut names = std::collections::HashMap::new();
435 names.insert(pubkey.clone(), "Alice".to_string());
436 names.insert(pk2.clone(), "Bob".to_string());
437 let meta = types::Meta {
438 recipients: names,
439 mac: String::new(),
440 hmac_key: None,
441 };
442 let meta_json = serde_json::to_vec(&meta).unwrap();
443 let r2 = make_recipient(&pk2);
444 let meta_enc = crate::encrypt_value(&meta_json, &[recipient, r2]).unwrap();
445
446 let mut vault = empty_vault();
447 vault.recipients = vec![pubkey.clone(), pk2.clone()];
448 vault.meta = meta_enc;
449
450 let entries = list_recipients(&vault, Some(&secret));
451 assert_eq!(entries.len(), 2);
452 let me = entries.iter().find(|e| e.pubkey == pubkey).unwrap();
453 assert!(me.is_self);
454 assert_eq!(me.display_name.as_deref(), Some("Alice"));
455 let other = entries.iter().find(|e| e.pubkey == pk2).unwrap();
456 assert!(!other.is_self);
457 assert_eq!(other.display_name.as_deref(), Some("Bob"));
458 }
459
460 #[test]
461 fn list_recipients_without_key() {
462 let (_, pubkey) = generate_keypair();
463 let mut vault = empty_vault();
464 vault.recipients = vec![pubkey.clone()];
465
466 let entries = list_recipients(&vault, None);
467 assert_eq!(entries.len(), 1);
468 assert_eq!(entries[0].pubkey, pubkey);
469 assert!(entries[0].display_name.is_none());
470 assert!(!entries[0].is_self);
471 }
472
473 #[test]
474 fn list_recipients_wrong_key() {
475 let (_, pubkey) = generate_keypair();
476 let recipient = make_recipient(&pubkey);
477 let (wrong_secret, _) = generate_keypair();
478
479 let meta = types::Meta {
480 recipients: std::collections::HashMap::from([(pubkey.clone(), "Alice".into())]),
481 mac: String::new(),
482 hmac_key: None,
483 };
484 let meta_json = serde_json::to_vec(&meta).unwrap();
485 let meta_enc = crate::encrypt_value(&meta_json, &[recipient]).unwrap();
486
487 let mut vault = empty_vault();
488 vault.recipients = vec![pubkey.clone()];
489 vault.meta = meta_enc;
490
491 let entries = list_recipients(&vault, Some(&wrong_secret));
492 assert_eq!(entries.len(), 1);
493 assert!(entries[0].display_name.is_none());
494 assert!(!entries[0].is_self);
495 }
496
497 #[test]
498 fn list_recipients_empty_vault() {
499 let vault = empty_vault();
500 let entries = list_recipients(&vault, None);
501 assert!(entries.is_empty());
502 }
503
504 #[test]
505 fn revoke_recipient_no_scoped() {
506 let (_, pk1) = generate_keypair();
507 let (_, pk2) = generate_keypair();
508 let mut vault = empty_vault();
509 vault.recipients = vec![pk1.clone(), pk2.clone()];
510 let mut murk = empty_murk();
511 murk.recipients.insert(pk2.clone(), "bob".into());
512
513 let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
514 assert_eq!(result.display_name.as_deref(), Some("bob"));
515 assert!(!vault.recipients.contains(&pk2));
516 }
517
518 #[test]
519 fn revoke_by_name_removes_all_matching_keys() {
520 let (_, pk_owner) = generate_keypair();
521 let (_, pk_ssh1) = generate_keypair();
522 let (_, pk_ssh2) = generate_keypair();
523 let mut vault = empty_vault();
524 vault.recipients = vec![pk_owner.clone(), pk_ssh1.clone(), pk_ssh2.clone()];
525 let mut murk = empty_murk();
526 murk.recipients
527 .insert(pk_ssh1.clone(), "alice@github".into());
528 murk.recipients
529 .insert(pk_ssh2.clone(), "alice@github".into());
530
531 let result = revoke_recipient(&mut vault, &mut murk, "alice@github").unwrap();
532 assert_eq!(result.display_name.as_deref(), Some("alice@github"));
533 assert!(!vault.recipients.contains(&pk_ssh1));
534 assert!(!vault.recipients.contains(&pk_ssh2));
535 assert!(vault.recipients.contains(&pk_owner));
536 }
537
538 #[test]
539 fn revoke_all_matching_blocked_if_last() {
540 let (_, pk_ssh1) = generate_keypair();
541 let (_, pk_ssh2) = generate_keypair();
542 let mut vault = empty_vault();
543 vault.recipients = vec![pk_ssh1.clone(), pk_ssh2.clone()];
544 let mut murk = empty_murk();
545 murk.recipients
546 .insert(pk_ssh1.clone(), "alice@github".into());
547 murk.recipients
548 .insert(pk_ssh2.clone(), "alice@github".into());
549
550 let result = revoke_recipient(&mut vault, &mut murk, "alice@github");
551 assert!(result.is_err());
552 assert!(result.unwrap_err().contains("cannot revoke last recipient"));
553 }
554
555 #[test]
558 fn truncate_age_key() {
559 let pk = "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p";
560 let truncated = truncate_pubkey(pk);
561 assert!(truncated.len() < pk.len());
562 assert!(truncated.starts_with("age1ql3z"));
563 assert!(truncated.contains('…'));
564 }
565
566 #[test]
567 fn truncate_ssh_key() {
568 let pk = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGVsample";
569 let truncated = truncate_pubkey(pk);
570 assert!(!truncated.starts_with("ssh-ed25519"));
571 assert!(truncated.contains('…'));
572 }
573
574 #[test]
575 fn truncate_short_key_unchanged() {
576 assert_eq!(truncate_pubkey("age1short"), "age1short");
577 }
578
579 #[test]
580 fn key_type_labels() {
581 assert_eq!(key_type_label("age1abc"), "age");
582 assert_eq!(key_type_label("ssh-ed25519 AAAA"), "ed25519");
583 assert_eq!(key_type_label("ssh-rsa AAAA"), "rsa");
584 }
585
586 #[test]
587 fn format_recipients_no_names() {
588 let entries = vec![
589 RecipientEntry {
590 pubkey: "age1abc".into(),
591 display_name: None,
592 is_self: false,
593 },
594 RecipientEntry {
595 pubkey: "age1xyz".into(),
596 display_name: None,
597 is_self: false,
598 },
599 ];
600 let lines = format_recipient_lines(&entries);
601 assert_eq!(lines, vec!["age1abc", "age1xyz"]);
602 }
603
604 #[test]
605 fn format_recipients_with_names() {
606 let entries = vec![
607 RecipientEntry {
608 pubkey: "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p".into(),
609 display_name: Some("alice".into()),
610 is_self: true,
611 },
612 RecipientEntry {
613 pubkey: "age1xyz7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p".into(),
614 display_name: Some("bob".into()),
615 is_self: false,
616 },
617 ];
618 let lines = format_recipient_lines(&entries);
619 assert_eq!(lines.len(), 2);
620 assert!(lines[0].starts_with("◆"));
621 assert!(lines[0].contains("alice"));
622 assert!(lines[1].starts_with(" "));
623 assert!(lines[1].contains("bob"));
624 }
625
626 #[test]
627 fn format_recipients_groups_multi_key() {
628 let entries = vec![
629 RecipientEntry {
630 pubkey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGVkey1sample".into(),
631 display_name: Some("alice@github".into()),
632 is_self: false,
633 },
634 RecipientEntry {
635 pubkey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGVkey2sample".into(),
636 display_name: Some("alice@github".into()),
637 is_self: false,
638 },
639 ];
640 let lines = format_recipient_lines(&entries);
641 assert_eq!(lines.len(), 1);
642 assert!(lines[0].contains("(2 keys)"));
643 }
644}