1use std::collections::HashMap;
2use std::io::{Read, Write};
3
4use age::cli_common::UiCallbacks;
5use age::plugin::{
6 Identity as PluginIdentity, IdentityPluginV1, Recipient as PluginRecipient, RecipientPluginV1,
7};
8use zeroize::Zeroizing;
9
10#[derive(Debug)]
12pub enum CryptoError {
13 Encrypt(String),
14 Decrypt(String),
15 InvalidKey(String),
16}
17
18impl std::fmt::Display for CryptoError {
19 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20 match self {
21 CryptoError::Encrypt(msg) => write!(f, "encryption failed: {msg}"),
22 CryptoError::Decrypt(msg) => write!(f, "decryption failed: {msg}"),
23 CryptoError::InvalidKey(msg) => write!(f, "invalid key: {msg}"),
24 }
25 }
26}
27
28#[derive(Clone)]
34pub enum MurkRecipient {
35 Age(age::x25519::Recipient),
36 Ssh(age::ssh::Recipient),
37 Plugin(PluginRecipient),
38}
39
40impl std::fmt::Debug for MurkRecipient {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 match self {
43 MurkRecipient::Age(r) => write!(f, "Age({r})"),
44 MurkRecipient::Ssh(r) => write!(f, "Ssh({r})"),
45 MurkRecipient::Plugin(r) => write!(f, "Plugin({r})"),
46 }
47 }
48}
49
50#[derive(Clone)]
57pub enum MurkIdentity {
58 Age(age::x25519::Identity),
59 Ssh {
60 identity: age::ssh::Identity,
61 pem: Zeroizing<String>,
65 },
66 Plugin {
67 identity: PluginIdentity,
68 pubkey: String,
69 },
70}
71
72impl std::fmt::Debug for MurkIdentity {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 match self {
77 MurkIdentity::Age(_) => write!(f, "Age(<redacted>)"),
78 MurkIdentity::Ssh { .. } => write!(f, "Ssh(<redacted>)"),
79 MurkIdentity::Plugin { pubkey, identity } => {
80 write!(f, "Plugin({} → {pubkey})", identity.plugin())
81 }
82 }
83 }
84}
85
86impl MurkIdentity {
87 pub fn pubkey_string(&self) -> Result<String, CryptoError> {
94 match self {
95 MurkIdentity::Age(id) => Ok(id.to_public().to_string()),
96 MurkIdentity::Ssh { identity, .. } => {
97 let recipient = age::ssh::Recipient::try_from(identity.clone()).map_err(|e| {
98 CryptoError::InvalidKey(format!("cannot derive SSH public key: {e:?}"))
99 })?;
100 Ok(recipient.to_string())
101 }
102 MurkIdentity::Plugin { pubkey, .. } => Ok(pubkey.clone()),
103 }
104 }
105
106 pub fn plugin_name(&self) -> Option<&str> {
108 match self {
109 MurkIdentity::Plugin { identity, .. } => Some(identity.plugin()),
110 _ => None,
111 }
112 }
113
114 pub fn is_signing_capable(&self) -> bool {
119 match self {
120 MurkIdentity::Age(_) => true,
121 MurkIdentity::Ssh { .. } => self
122 .pubkey_string()
123 .is_ok_and(|s| s.starts_with("ssh-ed25519 ")),
124 MurkIdentity::Plugin { .. } => false,
125 }
126 }
127
128 pub fn registers_verifying_key(&self) -> bool {
136 matches!(self, MurkIdentity::Age(_))
137 }
138
139 pub fn signing_key(&self) -> Option<ed25519_dalek::SigningKey> {
148 match self {
149 MurkIdentity::Age(id) => {
150 use age::secrecy::ExposeSecret;
151 let secret = id.to_string();
153 let lower = Zeroizing::new(secret.expose_secret().to_lowercase());
154 let (_, bytes) = bech32::decode(&lower).ok()?;
155 let bytes = Zeroizing::new(bytes);
156 Some(crate::signing::signing_key_from_age_bytes(&bytes))
157 }
158 MurkIdentity::Ssh { pem, .. } => crate::signing::ed25519_signing_key_from_openssh(pem),
159 MurkIdentity::Plugin { .. } => None,
160 }
161 }
162}
163
164pub fn parse_recipient(pubkey: &str) -> Result<MurkRecipient, CryptoError> {
169 if let Ok(r) = pubkey.parse::<age::x25519::Recipient>() {
170 return Ok(MurkRecipient::Age(r));
171 }
172 if let Ok(r) = pubkey.parse::<age::ssh::Recipient>() {
173 return Ok(MurkRecipient::Ssh(r));
174 }
175 if let Ok(r) = pubkey.parse::<PluginRecipient>() {
176 return Ok(MurkRecipient::Plugin(r));
177 }
178 Err(CryptoError::InvalidKey(format!(
179 "not a valid age, SSH, or plugin public key: {pubkey}"
180 )))
181}
182
183pub fn parse_identity(input: &str) -> Result<MurkIdentity, CryptoError> {
195 let trimmed = input.trim();
196 if let Ok(id) = trimmed.parse::<age::x25519::Identity>() {
197 return Ok(MurkIdentity::Age(id));
198 }
199
200 let reader = std::io::BufReader::new(input.as_bytes());
202 if let Ok(id) = age::ssh::Identity::from_buffer(reader, None) {
203 match id {
204 age::ssh::Identity::Unencrypted(_) => {
205 return Ok(MurkIdentity::Ssh {
208 identity: id,
209 pem: Zeroizing::new(input.to_string()),
210 });
211 }
212 age::ssh::Identity::Encrypted(_) => {
213 return Err(CryptoError::InvalidKey(
214 "encrypted SSH keys are not yet supported — use an unencrypted key or an age key"
215 .into(),
216 ));
217 }
218 age::ssh::Identity::Unsupported(k) => {
219 return Err(CryptoError::InvalidKey(format!(
220 "unsupported SSH key type: {k:?}"
221 )));
222 }
223 }
224 }
225
226 let mut pubkey: Option<String> = None;
231 for line in input.lines() {
232 let line = line.trim();
233 if line.is_empty() {
234 continue;
235 }
236 if let Some(rest) = line.strip_prefix('#').map(str::trim).and_then(|s| {
237 let lower = s.to_ascii_lowercase();
238 ["public key:", "recipient:"].iter().find_map(|p| {
239 lower
240 .starts_with(p)
241 .then(|| s[p.len()..].trim().to_string())
242 })
243 }) {
244 pubkey = Some(rest);
245 continue;
246 }
247 if line.starts_with('#') {
248 continue;
249 }
250 if let Ok(identity) = line.parse::<PluginIdentity>() {
251 let pk = pubkey.ok_or_else(|| {
252 CryptoError::InvalidKey(
253 "plugin identity is missing its recipient header (`# public key: age1...` \
254 or `# Recipient: age1...`). Save the plugin output (the header line PLUS \
255 the AGE-PLUGIN-... line) to a file and set MURK_KEY_FILE to its path — \
256 setting MURK_KEY to just the identity string is not enough, because murk \
257 needs the recipient pubkey"
258 .into(),
259 )
260 })?;
261 parse_recipient(&pk).map_err(|e| {
262 CryptoError::InvalidKey(format!(
263 "`# public key:` header in identity file is not a valid recipient: {e}"
264 ))
265 })?;
266 return Ok(MurkIdentity::Plugin {
267 identity,
268 pubkey: pk,
269 });
270 }
271 if let Ok(id) = line.parse::<age::x25519::Identity>() {
273 return Ok(MurkIdentity::Age(id));
274 }
275 break;
276 }
277
278 Err(CryptoError::InvalidKey(
279 "not a valid age secret key, SSH private key, or plugin identity file".into(),
280 ))
281}
282
283pub fn encrypt(plaintext: &[u8], recipients: &[MurkRecipient]) -> Result<Vec<u8>, CryptoError> {
288 let mut native: Vec<&dyn age::Recipient> = vec![];
289 let mut grouped: HashMap<String, Vec<PluginRecipient>> = HashMap::new();
290
291 for r in recipients {
292 match r {
293 MurkRecipient::Age(r) => native.push(r),
294 MurkRecipient::Ssh(r) => native.push(r),
295 MurkRecipient::Plugin(r) => grouped
296 .entry(r.plugin().to_string())
297 .or_default()
298 .push(r.clone()),
299 }
300 }
301
302 let mut plugins: Vec<RecipientPluginV1<UiCallbacks>> = vec![];
303 for (name, plugin_recipients) in grouped {
304 let plugin = RecipientPluginV1::new(&name, &plugin_recipients, &[], UiCallbacks)
305 .map_err(|e| CryptoError::Encrypt(format!("age-plugin-{name} unavailable: {e}")))?;
306 plugins.push(plugin);
307 }
308
309 let mut all_refs: Vec<&dyn age::Recipient> = native;
310 for plugin in &plugins {
311 all_refs.push(plugin);
312 }
313
314 let encryptor = age::Encryptor::with_recipients(all_refs.into_iter())
315 .map_err(|e| CryptoError::Encrypt(e.to_string()))?;
316
317 let mut ciphertext = vec![];
318 let mut writer = encryptor
319 .wrap_output(&mut ciphertext)
320 .map_err(|e| CryptoError::Encrypt(e.to_string()))?;
321
322 writer
323 .write_all(plaintext)
324 .map_err(|e| CryptoError::Encrypt(e.to_string()))?;
325
326 writer
329 .finish()
330 .map_err(|e| CryptoError::Encrypt(e.to_string()))?;
331
332 Ok(ciphertext)
333}
334
335pub fn decrypt(
344 ciphertext: &[u8],
345 identity: &MurkIdentity,
346) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
347 let decryptor = age::Decryptor::new_buffered(ciphertext)
348 .map_err(|e| CryptoError::Decrypt(e.to_string()))?;
349
350 let mut plaintext = Zeroizing::new(vec![]);
351
352 let plugin_holder: Option<IdentityPluginV1<UiCallbacks>> = match identity {
354 MurkIdentity::Plugin { identity, .. } => Some(
355 IdentityPluginV1::new(
356 identity.plugin(),
357 std::slice::from_ref(identity),
358 UiCallbacks,
359 )
360 .map_err(|e| {
361 CryptoError::Decrypt(format!("age-plugin-{} unavailable: {e}", identity.plugin()))
362 })?,
363 ),
364 _ => None,
365 };
366
367 let id_ref: &dyn age::Identity = match identity {
368 MurkIdentity::Age(id) => id,
369 MurkIdentity::Ssh { identity, .. } => identity,
370 MurkIdentity::Plugin { .. } => plugin_holder.as_ref().expect("constructed above"),
371 };
372
373 let mut reader = decryptor
374 .decrypt(std::iter::once(id_ref))
375 .map_err(|e| CryptoError::Decrypt(e.to_string()))?;
376
377 reader
378 .read_to_end(&mut plaintext)
379 .map_err(|e| CryptoError::Decrypt(e.to_string()))?;
380
381 Ok(plaintext)
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387 use age::secrecy::ExposeSecret;
388
389 fn generate_keypair() -> (String, String) {
390 let identity = age::x25519::Identity::generate();
391 let secret = identity.to_string();
392 let pubkey = identity.to_public().to_string();
393 (secret.expose_secret().to_string(), pubkey)
394 }
395
396 #[test]
397 fn roundtrip_single_recipient() {
398 let (secret, pubkey) = generate_keypair();
399 let recipient = parse_recipient(&pubkey).unwrap();
400 let identity = parse_identity(&secret).unwrap();
401
402 let plaintext = b"hello darkness";
403 let ciphertext = encrypt(plaintext, &[recipient]).unwrap();
404 let decrypted = decrypt(&ciphertext, &identity).unwrap();
405
406 assert_eq!(&decrypted[..], plaintext);
407 }
408
409 #[test]
410 fn roundtrip_multiple_recipients() {
411 let (secret_a, pubkey_a) = generate_keypair();
412 let (secret_b, pubkey_b) = generate_keypair();
413
414 let recipients = vec![
415 parse_recipient(&pubkey_a).unwrap(),
416 parse_recipient(&pubkey_b).unwrap(),
417 ];
418
419 let plaintext = b"sharing is caring";
420 let ciphertext = encrypt(plaintext, &recipients).unwrap();
421
422 let id_a = parse_identity(&secret_a).unwrap();
424 let id_b = parse_identity(&secret_b).unwrap();
425 assert_eq!(&decrypt(&ciphertext, &id_a).unwrap()[..], plaintext);
426 assert_eq!(&decrypt(&ciphertext, &id_b).unwrap()[..], plaintext);
427 }
428
429 #[test]
430 fn wrong_key_fails() {
431 let (_secret, pubkey) = generate_keypair();
432 let (wrong_secret, _) = generate_keypair();
433
434 let recipient = parse_recipient(&pubkey).unwrap();
435 let wrong_identity = parse_identity(&wrong_secret).unwrap();
436
437 let ciphertext = encrypt(b"none of your business", &[recipient]).unwrap();
438 assert!(decrypt(&ciphertext, &wrong_identity).is_err());
439 }
440
441 #[test]
442 fn invalid_key_strings() {
443 assert!(parse_recipient("sine-loco").is_err());
444 assert!(parse_identity("nihil-et-nemo").is_err());
445 }
446
447 fn make_plugin_pair(plugin: &str) -> (String, String) {
453 use bech32::{Bech32, Hrp};
454 let entropy = [0u8; 20];
455 let identity_hrp = Hrp::parse(&format!("age-plugin-{plugin}-")).unwrap();
456 let identity = bech32::encode::<Bech32>(identity_hrp, &entropy)
457 .unwrap()
458 .to_uppercase();
459 let recipient_hrp = Hrp::parse(&format!("age1{plugin}")).unwrap();
460 let recipient = bech32::encode::<Bech32>(recipient_hrp, &entropy).unwrap();
461 (identity, recipient)
462 }
463
464 #[test]
465 fn parse_identity_plugin_file() {
466 let (identity_str, pubkey_str) = make_plugin_pair("yubikey");
467 let file = format!(
468 "# created: 2024-01-01T00:00:00-00:00\n# public key: {pubkey_str}\n{identity_str}\n"
469 );
470 let id = parse_identity(&file).expect("parses plugin identity file");
471 match &id {
472 MurkIdentity::Plugin { identity, pubkey } => {
473 assert_eq!(identity.plugin(), "yubikey");
474 assert_eq!(pubkey, &pubkey_str);
475 }
476 _ => panic!("expected Plugin variant, got {id:?}"),
477 }
478 assert_eq!(id.pubkey_string().unwrap(), pubkey_str);
479 }
480
481 #[test]
482 fn parse_identity_plugin_file_recipient_header() {
483 let (identity_str, pubkey_str) = make_plugin_pair("yubikey");
486 let file = format!(
487 "# Serial: 17600929, Slot: 1\n# Name: murk-test\n\
488 # Recipient: {pubkey_str}\n{identity_str}\n"
489 );
490 let id = parse_identity(&file).expect("parses `# Recipient:` plugin file");
491 match &id {
492 MurkIdentity::Plugin { identity, pubkey } => {
493 assert_eq!(identity.plugin(), "yubikey");
494 assert_eq!(pubkey, &pubkey_str);
495 }
496 _ => panic!("expected Plugin variant, got {id:?}"),
497 }
498 }
499
500 #[test]
501 fn is_signing_capable_by_identity_kind() {
502 let (secret, _) = generate_keypair();
504 assert!(parse_identity(&secret).unwrap().is_signing_capable());
505
506 let (identity_str, pubkey_str) = make_plugin_pair("yubikey");
508 let file = format!("# public key: {pubkey_str}\n{identity_str}\n");
509 assert!(!parse_identity(&file).unwrap().is_signing_capable());
510 }
511
512 #[test]
513 fn parse_identity_plugin_file_missing_pubkey_header() {
514 let (identity_str, _) = make_plugin_pair("yubikey");
515 let err = parse_identity(&format!("{identity_str}\n"))
516 .unwrap_err()
517 .to_string();
518 assert!(
519 err.contains("public key") && err.contains("MURK_KEY_FILE"),
520 "expected pubkey + MURK_KEY_FILE guidance, got: {err}"
521 );
522 }
523
524 #[test]
525 fn parse_recipient_plugin_yubikey() {
526 let (_, pubkey_str) = make_plugin_pair("yubikey");
527 let r = parse_recipient(&pubkey_str).unwrap();
528 assert!(matches!(r, MurkRecipient::Plugin(_)));
529 }
530
531 #[test]
532 fn plugin_identity_trailing_whitespace_tolerated() {
533 let (identity_str, pubkey_str) = make_plugin_pair("yubikey");
534 let file = format!("\n\n# public key: {pubkey_str}\n{identity_str}\n\n");
535 let id = parse_identity(&file).expect("parses with extra whitespace");
536 assert_eq!(id.plugin_name(), Some("yubikey"));
537 }
538
539 #[test]
542 fn encrypt_empty_plaintext() {
543 let (secret, pubkey) = generate_keypair();
544 let recipient = parse_recipient(&pubkey).unwrap();
545 let identity = parse_identity(&secret).unwrap();
546
547 let ciphertext = encrypt(b"", &[recipient]).unwrap();
548 let decrypted = decrypt(&ciphertext, &identity).unwrap();
549 assert!(decrypted.is_empty());
550 }
551
552 #[test]
553 fn decrypt_corrupted_ciphertext() {
554 let (secret, _) = generate_keypair();
555 let identity = parse_identity(&secret).unwrap();
556 assert!(decrypt(b"this is not valid ciphertext", &identity).is_err());
557 }
558
559 #[test]
560 fn parse_recipient_ssh_ed25519() {
561 let key =
563 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHsKLqeplhpW+uObz5dvMgjz1OxfM/XXUB+VHtZ6isGN";
564 let r = parse_recipient(key);
565 assert!(r.is_ok());
566 assert!(matches!(r.unwrap(), MurkRecipient::Ssh(_)));
567 }
568
569 #[test]
570 fn parse_recipient_age_key() {
571 let (_, pubkey) = generate_keypair();
572 let r = parse_recipient(&pubkey);
573 assert!(r.is_ok());
574 assert!(matches!(r.unwrap(), MurkRecipient::Age(_)));
575 }
576
577 #[test]
578 fn pubkey_string_age() {
579 let (secret, pubkey) = generate_keypair();
580 let id = parse_identity(&secret).unwrap();
581 assert_eq!(id.pubkey_string().unwrap(), pubkey);
582 }
583
584 #[test]
585 fn parse_identity_ssh_unencrypted() {
586 let sk = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQAAAJCfEwtqnxML\nagAAAAtzc2gtZWQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQ\nAAAEADBJvjZT8X6JRJI8xVq/1aU8nMVgOtVnmdwqWwrSlXG3sKLqeplhpW+uObz5dvMgjz\n1OxfM/XXUB+VHtZ6isGNAAAADHN0cjRkQGNhcmJvbgE=\n-----END OPENSSH PRIVATE KEY-----";
588 let id = parse_identity(sk);
589 assert!(id.is_ok());
590 assert!(matches!(id.unwrap(), MurkIdentity::Ssh { .. }));
591 }
592
593 #[test]
594 fn ssh_identity_pubkey_roundtrip() {
595 let sk = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQAAAJCfEwtqnxML\nagAAAAtzc2gtZWQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQ\nAAAEADBJvjZT8X6JRJI8xVq/1aU8nMVgOtVnmdwqWwrSlXG3sKLqeplhpW+uObz5dvMgjz\n1OxfM/XXUB+VHtZ6isGNAAAADHN0cjRkQGNhcmJvbgE=\n-----END OPENSSH PRIVATE KEY-----";
596 let id = parse_identity(sk).unwrap();
597 let pubkey = id.pubkey_string().unwrap();
598 assert!(pubkey.starts_with("ssh-ed25519 "));
599
600 let recipient = parse_recipient(&pubkey);
602 assert!(recipient.is_ok());
603 }
604
605 #[test]
606 fn ssh_encrypt_decrypt_roundtrip() {
607 let sk = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQAAAJCfEwtqnxML\nagAAAAtzc2gtZWQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQ\nAAAEADBJvjZT8X6JRJI8xVq/1aU8nMVgOtVnmdwqWwrSlXG3sKLqeplhpW+uObz5dvMgjz\n1OxfM/XXUB+VHtZ6isGNAAAADHN0cjRkQGNhcmJvbgE=\n-----END OPENSSH PRIVATE KEY-----";
608 let id = parse_identity(sk).unwrap();
609 let pubkey = id.pubkey_string().unwrap();
610 let recipient = parse_recipient(&pubkey).unwrap();
611
612 let plaintext = b"ssh secrets";
613 let ciphertext = encrypt(plaintext, &[recipient]).unwrap();
614 let decrypted = decrypt(&ciphertext, &id).unwrap();
615 assert_eq!(&decrypted[..], plaintext);
616 }
617
618 #[test]
619 fn mixed_age_and_ssh_recipients() {
620 let (age_secret, age_pubkey) = generate_keypair();
622
623 let ssh_sk = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQAAAJCfEwtqnxML\nagAAAAtzc2gtZWQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQ\nAAAEADBJvjZT8X6JRJI8xVq/1aU8nMVgOtVnmdwqWwrSlXG3sKLqeplhpW+uObz5dvMgjz\n1OxfM/XXUB+VHtZ6isGNAAAADHN0cjRkQGNhcmJvbgE=\n-----END OPENSSH PRIVATE KEY-----";
625 let ssh_id = parse_identity(ssh_sk).unwrap();
626 let ssh_pubkey = ssh_id.pubkey_string().unwrap();
627
628 let recipients = vec![
630 parse_recipient(&age_pubkey).unwrap(),
631 parse_recipient(&ssh_pubkey).unwrap(),
632 ];
633 let plaintext = b"shared between age and ssh";
634 let ciphertext = encrypt(plaintext, &recipients).unwrap();
635
636 let age_id = parse_identity(&age_secret).unwrap();
638 assert_eq!(&decrypt(&ciphertext, &age_id).unwrap()[..], plaintext);
639 assert_eq!(&decrypt(&ciphertext, &ssh_id).unwrap()[..], plaintext);
640 }
641}