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 `# public key:` header.
86    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    /// Plugin name (e.g. `"yubikey"`, `"se"`) if this is a plugin identity.
100    pub fn plugin_name(&self) -> Option<&str> {
101        match self {
102            MurkIdentity::Plugin { identity, .. } => Some(identity.plugin()),
103            _ => None,
104        }
105    }
106}
107
108/// Parse a public key string into a `MurkRecipient`.
109///
110/// Tries x25519 (`age1...`), then SSH (`ssh-ed25519 ...` / `ssh-rsa ...`),
111/// then age plugin recipients (`age1<plugin>1...`).
112pub 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
127/// Parse a secret key or identity-file contents into a `MurkIdentity`.
128///
129/// Accepts three shapes:
130/// - A bare age secret key (`AGE-SECRET-KEY-1...`)
131/// - An SSH PEM-encoded private key (unencrypted only; encrypted keys are rejected)
132/// - A plugin identity file — multi-line text with a `# public key: age1...`
133///   header followed by an `AGE-PLUGIN-<NAME>-1...` pointer, as produced by
134///   tools like `age-plugin-yubikey --identity`
135///
136/// Comments and blank lines are permitted anywhere.
137pub 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    // SSH PEM has its own framing; feed the full input.
144    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    // Identity-file form: walk lines, capture `# public key:` header, then
163    // accept a following plugin pointer.
164    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        // Unrecognised non-comment line — retry as an age key for trailing-whitespace tolerance.
202        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
213/// Encrypt plaintext bytes to one or more recipients.
214///
215/// Plugin recipients are grouped by plugin name and dispatched via
216/// [`RecipientPluginV1`]. Native (age/ssh) recipients pass through directly.
217pub 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    // finish() is critical — without it, the output is silently
257    // truncated and undecryptable. No error, just broken data.
258    writer
259        .finish()
260        .map_err(|e| CryptoError::Encrypt(e.to_string()))?;
261
262    Ok(ciphertext)
263}
264
265/// Decrypt ciphertext using an identity (age, SSH, or plugin).
266///
267/// Returns the plaintext wrapped in `Zeroizing<Vec<u8>>` so the buffer is
268/// cleared when dropped. Defense-in-depth against plaintext lingering in
269/// freed heap memory.
270///
271/// For plugin identities this spawns `age-plugin-<name>` and may prompt
272/// the user (YubiKey touch, Touch ID, PIN entry) via [`UiCallbacks`].
273pub 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    // Hold the plugin object outside the match so the &dyn borrow stays valid.
283    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        // Both recipients can decrypt
353        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    // ── Plugin identity tests ──
378
379    /// Build a syntactically-valid plugin identity + recipient pair for a
380    /// given plugin name. Uses bech32 with dummy entropy — these tests verify
381    /// parsing and dispatch, not plugin interop.
382    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    // ── New edge-case tests ──
439
440    #[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        // A valid ssh-ed25519 public key (without comment)
461        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        // Unencrypted ed25519 SSH key from age's test suite.
486        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        // The derived pubkey should be parseable as a recipient.
500        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        // Age keypair.
520        let (age_secret, age_pubkey) = generate_keypair();
521
522        // SSH keypair.
523        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        // Encrypt to both.
528        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        // Both can decrypt.
536        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}