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(age::ssh::Identity),
60 Plugin {
61 identity: PluginIdentity,
62 pubkey: String,
63 },
64}
65
66impl std::fmt::Debug for MurkIdentity {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 match self {
71 MurkIdentity::Age(_) => write!(f, "Age(<redacted>)"),
72 MurkIdentity::Ssh(_) => write!(f, "Ssh(<redacted>)"),
73 MurkIdentity::Plugin { pubkey, identity } => {
74 write!(f, "Plugin({} → {pubkey})", identity.plugin())
75 }
76 }
77 }
78}
79
80impl MurkIdentity {
81 pub fn pubkey_string(&self) -> Result<String, CryptoError> {
88 match self {
89 MurkIdentity::Age(id) => Ok(id.to_public().to_string()),
90 MurkIdentity::Ssh(id) => {
91 let recipient = age::ssh::Recipient::try_from(id.clone()).map_err(|e| {
92 CryptoError::InvalidKey(format!("cannot derive SSH public key: {e:?}"))
93 })?;
94 Ok(recipient.to_string())
95 }
96 MurkIdentity::Plugin { pubkey, .. } => Ok(pubkey.clone()),
97 }
98 }
99
100 pub fn plugin_name(&self) -> Option<&str> {
102 match self {
103 MurkIdentity::Plugin { identity, .. } => Some(identity.plugin()),
104 _ => None,
105 }
106 }
107}
108
109pub fn parse_recipient(pubkey: &str) -> Result<MurkRecipient, CryptoError> {
114 if let Ok(r) = pubkey.parse::<age::x25519::Recipient>() {
115 return Ok(MurkRecipient::Age(r));
116 }
117 if let Ok(r) = pubkey.parse::<age::ssh::Recipient>() {
118 return Ok(MurkRecipient::Ssh(r));
119 }
120 if let Ok(r) = pubkey.parse::<PluginRecipient>() {
121 return Ok(MurkRecipient::Plugin(r));
122 }
123 Err(CryptoError::InvalidKey(format!(
124 "not a valid age, SSH, or plugin public key: {pubkey}"
125 )))
126}
127
128pub fn parse_identity(input: &str) -> Result<MurkIdentity, CryptoError> {
140 let trimmed = input.trim();
141 if let Ok(id) = trimmed.parse::<age::x25519::Identity>() {
142 return Ok(MurkIdentity::Age(id));
143 }
144
145 let reader = std::io::BufReader::new(input.as_bytes());
147 if let Ok(id) = age::ssh::Identity::from_buffer(reader, None) {
148 match id {
149 age::ssh::Identity::Unencrypted(_) => return Ok(MurkIdentity::Ssh(id)),
150 age::ssh::Identity::Encrypted(_) => {
151 return Err(CryptoError::InvalidKey(
152 "encrypted SSH keys are not yet supported — use an unencrypted key or an age key"
153 .into(),
154 ));
155 }
156 age::ssh::Identity::Unsupported(k) => {
157 return Err(CryptoError::InvalidKey(format!(
158 "unsupported SSH key type: {k:?}"
159 )));
160 }
161 }
162 }
163
164 let mut pubkey: Option<String> = None;
169 for line in input.lines() {
170 let line = line.trim();
171 if line.is_empty() {
172 continue;
173 }
174 if let Some(rest) = line.strip_prefix('#').map(str::trim).and_then(|s| {
175 let lower = s.to_ascii_lowercase();
176 ["public key:", "recipient:"].iter().find_map(|p| {
177 lower
178 .starts_with(p)
179 .then(|| s[p.len()..].trim().to_string())
180 })
181 }) {
182 pubkey = Some(rest);
183 continue;
184 }
185 if line.starts_with('#') {
186 continue;
187 }
188 if let Ok(identity) = line.parse::<PluginIdentity>() {
189 let pk = pubkey.ok_or_else(|| {
190 CryptoError::InvalidKey(
191 "plugin identity is missing its recipient header (`# public key: age1...` \
192 or `# Recipient: age1...`). Save the plugin output (the header line PLUS \
193 the AGE-PLUGIN-... line) to a file and set MURK_KEY_FILE to its path — \
194 setting MURK_KEY to just the identity string is not enough, because murk \
195 needs the recipient pubkey"
196 .into(),
197 )
198 })?;
199 parse_recipient(&pk).map_err(|e| {
200 CryptoError::InvalidKey(format!(
201 "`# public key:` header in identity file is not a valid recipient: {e}"
202 ))
203 })?;
204 return Ok(MurkIdentity::Plugin {
205 identity,
206 pubkey: pk,
207 });
208 }
209 if let Ok(id) = line.parse::<age::x25519::Identity>() {
211 return Ok(MurkIdentity::Age(id));
212 }
213 break;
214 }
215
216 Err(CryptoError::InvalidKey(
217 "not a valid age secret key, SSH private key, or plugin identity file".into(),
218 ))
219}
220
221pub fn encrypt(plaintext: &[u8], recipients: &[MurkRecipient]) -> Result<Vec<u8>, CryptoError> {
226 let mut native: Vec<&dyn age::Recipient> = vec![];
227 let mut grouped: HashMap<String, Vec<PluginRecipient>> = HashMap::new();
228
229 for r in recipients {
230 match r {
231 MurkRecipient::Age(r) => native.push(r),
232 MurkRecipient::Ssh(r) => native.push(r),
233 MurkRecipient::Plugin(r) => grouped
234 .entry(r.plugin().to_string())
235 .or_default()
236 .push(r.clone()),
237 }
238 }
239
240 let mut plugins: Vec<RecipientPluginV1<UiCallbacks>> = vec![];
241 for (name, plugin_recipients) in grouped {
242 let plugin = RecipientPluginV1::new(&name, &plugin_recipients, &[], UiCallbacks)
243 .map_err(|e| CryptoError::Encrypt(format!("age-plugin-{name} unavailable: {e}")))?;
244 plugins.push(plugin);
245 }
246
247 let mut all_refs: Vec<&dyn age::Recipient> = native;
248 for plugin in &plugins {
249 all_refs.push(plugin);
250 }
251
252 let encryptor = age::Encryptor::with_recipients(all_refs.into_iter())
253 .map_err(|e| CryptoError::Encrypt(e.to_string()))?;
254
255 let mut ciphertext = vec![];
256 let mut writer = encryptor
257 .wrap_output(&mut ciphertext)
258 .map_err(|e| CryptoError::Encrypt(e.to_string()))?;
259
260 writer
261 .write_all(plaintext)
262 .map_err(|e| CryptoError::Encrypt(e.to_string()))?;
263
264 writer
267 .finish()
268 .map_err(|e| CryptoError::Encrypt(e.to_string()))?;
269
270 Ok(ciphertext)
271}
272
273pub fn decrypt(
282 ciphertext: &[u8],
283 identity: &MurkIdentity,
284) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
285 let decryptor = age::Decryptor::new_buffered(ciphertext)
286 .map_err(|e| CryptoError::Decrypt(e.to_string()))?;
287
288 let mut plaintext = Zeroizing::new(vec![]);
289
290 let plugin_holder: Option<IdentityPluginV1<UiCallbacks>> = match identity {
292 MurkIdentity::Plugin { identity, .. } => Some(
293 IdentityPluginV1::new(
294 identity.plugin(),
295 std::slice::from_ref(identity),
296 UiCallbacks,
297 )
298 .map_err(|e| {
299 CryptoError::Decrypt(format!("age-plugin-{} unavailable: {e}", identity.plugin()))
300 })?,
301 ),
302 _ => None,
303 };
304
305 let id_ref: &dyn age::Identity = match identity {
306 MurkIdentity::Age(id) => id,
307 MurkIdentity::Ssh(id) => id,
308 MurkIdentity::Plugin { .. } => plugin_holder.as_ref().expect("constructed above"),
309 };
310
311 let mut reader = decryptor
312 .decrypt(std::iter::once(id_ref))
313 .map_err(|e| CryptoError::Decrypt(e.to_string()))?;
314
315 reader
316 .read_to_end(&mut plaintext)
317 .map_err(|e| CryptoError::Decrypt(e.to_string()))?;
318
319 Ok(plaintext)
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325 use age::secrecy::ExposeSecret;
326
327 fn generate_keypair() -> (String, String) {
328 let identity = age::x25519::Identity::generate();
329 let secret = identity.to_string();
330 let pubkey = identity.to_public().to_string();
331 (secret.expose_secret().to_string(), pubkey)
332 }
333
334 #[test]
335 fn roundtrip_single_recipient() {
336 let (secret, pubkey) = generate_keypair();
337 let recipient = parse_recipient(&pubkey).unwrap();
338 let identity = parse_identity(&secret).unwrap();
339
340 let plaintext = b"hello darkness";
341 let ciphertext = encrypt(plaintext, &[recipient]).unwrap();
342 let decrypted = decrypt(&ciphertext, &identity).unwrap();
343
344 assert_eq!(&decrypted[..], plaintext);
345 }
346
347 #[test]
348 fn roundtrip_multiple_recipients() {
349 let (secret_a, pubkey_a) = generate_keypair();
350 let (secret_b, pubkey_b) = generate_keypair();
351
352 let recipients = vec![
353 parse_recipient(&pubkey_a).unwrap(),
354 parse_recipient(&pubkey_b).unwrap(),
355 ];
356
357 let plaintext = b"sharing is caring";
358 let ciphertext = encrypt(plaintext, &recipients).unwrap();
359
360 let id_a = parse_identity(&secret_a).unwrap();
362 let id_b = parse_identity(&secret_b).unwrap();
363 assert_eq!(&decrypt(&ciphertext, &id_a).unwrap()[..], plaintext);
364 assert_eq!(&decrypt(&ciphertext, &id_b).unwrap()[..], plaintext);
365 }
366
367 #[test]
368 fn wrong_key_fails() {
369 let (_secret, pubkey) = generate_keypair();
370 let (wrong_secret, _) = generate_keypair();
371
372 let recipient = parse_recipient(&pubkey).unwrap();
373 let wrong_identity = parse_identity(&wrong_secret).unwrap();
374
375 let ciphertext = encrypt(b"none of your business", &[recipient]).unwrap();
376 assert!(decrypt(&ciphertext, &wrong_identity).is_err());
377 }
378
379 #[test]
380 fn invalid_key_strings() {
381 assert!(parse_recipient("sine-loco").is_err());
382 assert!(parse_identity("nihil-et-nemo").is_err());
383 }
384
385 fn make_plugin_pair(plugin: &str) -> (String, String) {
391 use bech32::{Bech32, Hrp};
392 let entropy = [0u8; 20];
393 let identity_hrp = Hrp::parse(&format!("age-plugin-{plugin}-")).unwrap();
394 let identity = bech32::encode::<Bech32>(identity_hrp, &entropy)
395 .unwrap()
396 .to_uppercase();
397 let recipient_hrp = Hrp::parse(&format!("age1{plugin}")).unwrap();
398 let recipient = bech32::encode::<Bech32>(recipient_hrp, &entropy).unwrap();
399 (identity, recipient)
400 }
401
402 #[test]
403 fn parse_identity_plugin_file() {
404 let (identity_str, pubkey_str) = make_plugin_pair("yubikey");
405 let file = format!(
406 "# created: 2024-01-01T00:00:00-00:00\n# public key: {pubkey_str}\n{identity_str}\n"
407 );
408 let id = parse_identity(&file).expect("parses plugin identity file");
409 match &id {
410 MurkIdentity::Plugin { identity, pubkey } => {
411 assert_eq!(identity.plugin(), "yubikey");
412 assert_eq!(pubkey, &pubkey_str);
413 }
414 _ => panic!("expected Plugin variant, got {id:?}"),
415 }
416 assert_eq!(id.pubkey_string().unwrap(), pubkey_str);
417 }
418
419 #[test]
420 fn parse_identity_plugin_file_recipient_header() {
421 let (identity_str, pubkey_str) = make_plugin_pair("yubikey");
424 let file = format!(
425 "# Serial: 17600929, Slot: 1\n# Name: murk-test\n\
426 # Recipient: {pubkey_str}\n{identity_str}\n"
427 );
428 let id = parse_identity(&file).expect("parses `# Recipient:` plugin file");
429 match &id {
430 MurkIdentity::Plugin { identity, pubkey } => {
431 assert_eq!(identity.plugin(), "yubikey");
432 assert_eq!(pubkey, &pubkey_str);
433 }
434 _ => panic!("expected Plugin variant, got {id:?}"),
435 }
436 }
437
438 #[test]
439 fn parse_identity_plugin_file_missing_pubkey_header() {
440 let (identity_str, _) = make_plugin_pair("yubikey");
441 let err = parse_identity(&format!("{identity_str}\n"))
442 .unwrap_err()
443 .to_string();
444 assert!(
445 err.contains("public key") && err.contains("MURK_KEY_FILE"),
446 "expected pubkey + MURK_KEY_FILE guidance, got: {err}"
447 );
448 }
449
450 #[test]
451 fn parse_recipient_plugin_yubikey() {
452 let (_, pubkey_str) = make_plugin_pair("yubikey");
453 let r = parse_recipient(&pubkey_str).unwrap();
454 assert!(matches!(r, MurkRecipient::Plugin(_)));
455 }
456
457 #[test]
458 fn plugin_identity_trailing_whitespace_tolerated() {
459 let (identity_str, pubkey_str) = make_plugin_pair("yubikey");
460 let file = format!("\n\n# public key: {pubkey_str}\n{identity_str}\n\n");
461 let id = parse_identity(&file).expect("parses with extra whitespace");
462 assert_eq!(id.plugin_name(), Some("yubikey"));
463 }
464
465 #[test]
468 fn encrypt_empty_plaintext() {
469 let (secret, pubkey) = generate_keypair();
470 let recipient = parse_recipient(&pubkey).unwrap();
471 let identity = parse_identity(&secret).unwrap();
472
473 let ciphertext = encrypt(b"", &[recipient]).unwrap();
474 let decrypted = decrypt(&ciphertext, &identity).unwrap();
475 assert!(decrypted.is_empty());
476 }
477
478 #[test]
479 fn decrypt_corrupted_ciphertext() {
480 let (secret, _) = generate_keypair();
481 let identity = parse_identity(&secret).unwrap();
482 assert!(decrypt(b"this is not valid ciphertext", &identity).is_err());
483 }
484
485 #[test]
486 fn parse_recipient_ssh_ed25519() {
487 let key =
489 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHsKLqeplhpW+uObz5dvMgjz1OxfM/XXUB+VHtZ6isGN";
490 let r = parse_recipient(key);
491 assert!(r.is_ok());
492 assert!(matches!(r.unwrap(), MurkRecipient::Ssh(_)));
493 }
494
495 #[test]
496 fn parse_recipient_age_key() {
497 let (_, pubkey) = generate_keypair();
498 let r = parse_recipient(&pubkey);
499 assert!(r.is_ok());
500 assert!(matches!(r.unwrap(), MurkRecipient::Age(_)));
501 }
502
503 #[test]
504 fn pubkey_string_age() {
505 let (secret, pubkey) = generate_keypair();
506 let id = parse_identity(&secret).unwrap();
507 assert_eq!(id.pubkey_string().unwrap(), pubkey);
508 }
509
510 #[test]
511 fn parse_identity_ssh_unencrypted() {
512 let sk = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQAAAJCfEwtqnxML\nagAAAAtzc2gtZWQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQ\nAAAEADBJvjZT8X6JRJI8xVq/1aU8nMVgOtVnmdwqWwrSlXG3sKLqeplhpW+uObz5dvMgjz\n1OxfM/XXUB+VHtZ6isGNAAAADHN0cjRkQGNhcmJvbgE=\n-----END OPENSSH PRIVATE KEY-----";
514 let id = parse_identity(sk);
515 assert!(id.is_ok());
516 assert!(matches!(id.unwrap(), MurkIdentity::Ssh(_)));
517 }
518
519 #[test]
520 fn ssh_identity_pubkey_roundtrip() {
521 let sk = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQAAAJCfEwtqnxML\nagAAAAtzc2gtZWQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQ\nAAAEADBJvjZT8X6JRJI8xVq/1aU8nMVgOtVnmdwqWwrSlXG3sKLqeplhpW+uObz5dvMgjz\n1OxfM/XXUB+VHtZ6isGNAAAADHN0cjRkQGNhcmJvbgE=\n-----END OPENSSH PRIVATE KEY-----";
522 let id = parse_identity(sk).unwrap();
523 let pubkey = id.pubkey_string().unwrap();
524 assert!(pubkey.starts_with("ssh-ed25519 "));
525
526 let recipient = parse_recipient(&pubkey);
528 assert!(recipient.is_ok());
529 }
530
531 #[test]
532 fn ssh_encrypt_decrypt_roundtrip() {
533 let sk = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQAAAJCfEwtqnxML\nagAAAAtzc2gtZWQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQ\nAAAEADBJvjZT8X6JRJI8xVq/1aU8nMVgOtVnmdwqWwrSlXG3sKLqeplhpW+uObz5dvMgjz\n1OxfM/XXUB+VHtZ6isGNAAAADHN0cjRkQGNhcmJvbgE=\n-----END OPENSSH PRIVATE KEY-----";
534 let id = parse_identity(sk).unwrap();
535 let pubkey = id.pubkey_string().unwrap();
536 let recipient = parse_recipient(&pubkey).unwrap();
537
538 let plaintext = b"ssh secrets";
539 let ciphertext = encrypt(plaintext, &[recipient]).unwrap();
540 let decrypted = decrypt(&ciphertext, &id).unwrap();
541 assert_eq!(&decrypted[..], plaintext);
542 }
543
544 #[test]
545 fn mixed_age_and_ssh_recipients() {
546 let (age_secret, age_pubkey) = generate_keypair();
548
549 let ssh_sk = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQAAAJCfEwtqnxML\nagAAAAtzc2gtZWQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQ\nAAAEADBJvjZT8X6JRJI8xVq/1aU8nMVgOtVnmdwqWwrSlXG3sKLqeplhpW+uObz5dvMgjz\n1OxfM/XXUB+VHtZ6isGNAAAADHN0cjRkQGNhcmJvbgE=\n-----END OPENSSH PRIVATE KEY-----";
551 let ssh_id = parse_identity(ssh_sk).unwrap();
552 let ssh_pubkey = ssh_id.pubkey_string().unwrap();
553
554 let recipients = vec![
556 parse_recipient(&age_pubkey).unwrap(),
557 parse_recipient(&ssh_pubkey).unwrap(),
558 ];
559 let plaintext = b"shared between age and ssh";
560 let ciphertext = encrypt(plaintext, &recipients).unwrap();
561
562 let age_id = parse_identity(&age_secret).unwrap();
564 assert_eq!(&decrypt(&ciphertext, &age_id).unwrap()[..], plaintext);
565 assert_eq!(&decrypt(&ciphertext, &ssh_id).unwrap()[..], plaintext);
566 }
567}