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