Skip to main content

murk_cli/
crypto.rs

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/// Errors that can occur during crypto operations.
11#[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/// A recipient that can receive age-encrypted data.
29///
30/// Wraps an age x25519 recipient, an SSH public key recipient, or a plugin
31/// recipient like `age1yubikey1...`. Plugin recipients dispatch to an external
32/// `age-plugin-<name>` binary during encryption.
33#[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/// An identity that can decrypt age-encrypted data.
51///
52/// Plugin identities (`AGE-PLUGIN-<NAME>-1...`) carry the recipient pubkey
53/// alongside the pointer so `pubkey_string` does not require spawning the
54/// plugin binary. Decryption spawns `age-plugin-<name>` via
55/// [`IdentityPluginV1`] to access the hardware-backed key.
56#[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
66/// Debug prints only the identity *kind*, never key material, to keep
67/// accidental logs from leaking secrets.
68impl 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    /// Return the public key string for this identity.
82    ///
83    /// For age keys: `age1...`. For SSH keys: `ssh-ed25519 AAAA...` or
84    /// `ssh-rsa AAAA...`. For plugin keys: the `age1<plugin>1...` recipient
85    /// that was parsed from the identity file's recipient header
86    /// (`# Recipient:` or `# public key:`).
87    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    /// Plugin name (e.g. `"yubikey"`, `"se"`) if this is a plugin identity.
101    pub fn plugin_name(&self) -> Option<&str> {
102        match self {
103            MurkIdentity::Plugin { identity, .. } => Some(identity.plugin()),
104            _ => None,
105        }
106    }
107}
108
109/// Parse a public key string into a `MurkRecipient`.
110///
111/// Tries x25519 (`age1...`), then SSH (`ssh-ed25519 ...` / `ssh-rsa ...`),
112/// then age plugin recipients (`age1<plugin>1...`).
113pub 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
128/// Parse a secret key or identity-file contents into a `MurkIdentity`.
129///
130/// Accepts three shapes:
131/// - A bare age secret key (`AGE-SECRET-KEY-1...`)
132/// - An SSH PEM-encoded private key (unencrypted only; encrypted keys are rejected)
133/// - A plugin identity file — multi-line text with a recipient header
134///   (`# Recipient: age1...` or `# public key: age1...`) followed by an
135///   `AGE-PLUGIN-<NAME>-1...` pointer, as produced by tools like
136///   `age-plugin-yubikey --identity`
137///
138/// Comments and blank lines are permitted anywhere.
139pub 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    // SSH PEM has its own framing; feed the full input.
146    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    // Identity-file form: walk lines, capture the recipient-pubkey header, then
165    // accept a following plugin pointer. age x25519/ssh files use
166    // `# public key:`; age-plugin-yubikey emits `# Recipient:`. Accept either,
167    // case-insensitively, so real plugin output parses without rewriting.
168    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        // Unrecognised non-comment line — retry as an age key for trailing-whitespace tolerance.
210        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
221/// Encrypt plaintext bytes to one or more recipients.
222///
223/// Plugin recipients are grouped by plugin name and dispatched via
224/// [`RecipientPluginV1`]. Native (age/ssh) recipients pass through directly.
225pub 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    // finish() is critical — without it, the output is silently
265    // truncated and undecryptable. No error, just broken data.
266    writer
267        .finish()
268        .map_err(|e| CryptoError::Encrypt(e.to_string()))?;
269
270    Ok(ciphertext)
271}
272
273/// Decrypt ciphertext using an identity (age, SSH, or plugin).
274///
275/// Returns the plaintext wrapped in `Zeroizing<Vec<u8>>` so the buffer is
276/// cleared when dropped. Defense-in-depth against plaintext lingering in
277/// freed heap memory.
278///
279/// For plugin identities this spawns `age-plugin-<name>` and may prompt
280/// the user (YubiKey touch, Touch ID, PIN entry) via [`UiCallbacks`].
281pub 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    // Hold the plugin object outside the match so the &dyn borrow stays valid.
291    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        // Both recipients can decrypt
361        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    // ── Plugin identity tests ──
386
387    /// Build a syntactically-valid plugin identity + recipient pair for a
388    /// given plugin name. Uses bech32 with dummy entropy — these tests verify
389    /// parsing and dispatch, not plugin interop.
390    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        // age-plugin-yubikey 0.5.1 emits `# Recipient:`, not `# public key:`.
422        // murk must accept it so the native identity file parses unmodified.
423        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    // ── New edge-case tests ──
466
467    #[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        // A valid ssh-ed25519 public key (without comment)
488        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        // Unencrypted ed25519 SSH key from age's test suite.
513        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        // The derived pubkey should be parseable as a recipient.
527        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        // Age keypair.
547        let (age_secret, age_pubkey) = generate_keypair();
548
549        // SSH keypair.
550        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        // Encrypt to both.
555        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        // Both can decrypt.
563        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}