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 {
60        identity: age::ssh::Identity,
61        /// The original OpenSSH private-key text. age does not expose the SSH
62        /// signing scalar, so we retain the PEM to derive an Ed25519 signing key
63        /// for vault signing (ssh-ed25519 only). Zeroized on drop.
64        pem: Zeroizing<String>,
65    },
66    Plugin {
67        identity: PluginIdentity,
68        pubkey: String,
69    },
70}
71
72/// Debug prints only the identity *kind*, never key material, to keep
73/// accidental logs from leaking secrets.
74impl 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    /// Return the public key string for this identity.
88    ///
89    /// For age keys: `age1...`. For SSH keys: `ssh-ed25519 AAAA...` or
90    /// `ssh-rsa AAAA...`. For plugin keys: the `age1<plugin>1...` recipient
91    /// that was parsed from the identity file's recipient header
92    /// (`# Recipient:` or `# public key:`).
93    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    /// Plugin name (e.g. `"yubikey"`, `"se"`) if this is a plugin identity.
107    pub fn plugin_name(&self) -> Option<&str> {
108        match self {
109            MurkIdentity::Plugin { identity, .. } => Some(identity.plugin()),
110            _ => None,
111        }
112    }
113
114    /// Whether this identity can produce vault signatures: native age keys, and
115    /// `ssh-ed25519` keys (which sign natively). `ssh-rsa` and hardware/plugin
116    /// identities cannot. Cheap check for deciding whether to nudge on an unsigned
117    /// vault; the actual key comes from [`Self::signing_key`].
118    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    /// Whether this identity's verifying key must be recorded in the signer
129    /// registry (`Meta::signers`) for its signature to be verifiable.
130    ///
131    /// Only age keys: their Ed25519 verifying key is derived from the secret and
132    /// cannot be recovered from the public age recipient, so it must be published.
133    /// `ssh-ed25519` verifying keys are embedded in the recipient string itself,
134    /// so they are self-authenticating and are NOT registered.
135    pub fn registers_verifying_key(&self) -> bool {
136        matches!(self, MurkIdentity::Age(_))
137    }
138
139    /// This identity's Ed25519 signing key, if it can sign.
140    ///
141    /// - Age keys: derived from the raw x25519 key bytes via a domain-separated
142    ///   KDF (see [`crate::signing`]), so it recovers from the BIP39 phrase.
143    /// - `ssh-ed25519` keys: the SSH key *is* an Ed25519 signing key; we parse it
144    ///   from the retained OpenSSH PEM (age does not expose the scalar). The
145    ///   resulting verifying key matches the `ssh-ed25519 …` recipient string.
146    /// - `ssh-rsa` and plugin/hardware identities return `None` (unsigned).
147    pub fn signing_key(&self) -> Option<ed25519_dalek::SigningKey> {
148        match self {
149            MurkIdentity::Age(id) => {
150                use age::secrecy::ExposeSecret;
151                // age keys display uppercase; bech32 decoding requires lowercase.
152                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
164/// Parse a public key string into a `MurkRecipient`.
165///
166/// Tries x25519 (`age1...`), then SSH (`ssh-ed25519 ...` / `ssh-rsa ...`),
167/// then age plugin recipients (`age1<plugin>1...`).
168pub 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
183/// Parse a secret key or identity-file contents into a `MurkIdentity`.
184///
185/// Accepts three shapes:
186/// - A bare age secret key (`AGE-SECRET-KEY-1...`)
187/// - An SSH PEM-encoded private key (unencrypted only; encrypted keys are rejected)
188/// - A plugin identity file — multi-line text with a recipient header
189///   (`# Recipient: age1...` or `# public key: age1...`) followed by an
190///   `AGE-PLUGIN-<NAME>-1...` pointer, as produced by tools like
191///   `age-plugin-yubikey --identity`
192///
193/// Comments and blank lines are permitted anywhere.
194pub 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    // SSH PEM has its own framing; feed the full input.
201    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                // Retain the PEM: age won't expose the SSH signing scalar, so we
206                // re-parse it (ssh-ed25519 only) when signing the vault.
207                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    // Identity-file form: walk lines, capture the recipient-pubkey header, then
227    // accept a following plugin pointer. age x25519/ssh files use
228    // `# public key:`; age-plugin-yubikey emits `# Recipient:`. Accept either,
229    // case-insensitively, so real plugin output parses without rewriting.
230    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        // Unrecognised non-comment line — retry as an age key for trailing-whitespace tolerance.
272        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
283/// Encrypt plaintext bytes to one or more recipients.
284///
285/// Plugin recipients are grouped by plugin name and dispatched via
286/// [`RecipientPluginV1`]. Native (age/ssh) recipients pass through directly.
287pub 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    // finish() is critical — without it, the output is silently
327    // truncated and undecryptable. No error, just broken data.
328    writer
329        .finish()
330        .map_err(|e| CryptoError::Encrypt(e.to_string()))?;
331
332    Ok(ciphertext)
333}
334
335/// Decrypt ciphertext using an identity (age, SSH, or plugin).
336///
337/// Returns the plaintext wrapped in `Zeroizing<Vec<u8>>` so the buffer is
338/// cleared when dropped. Defense-in-depth against plaintext lingering in
339/// freed heap memory.
340///
341/// For plugin identities this spawns `age-plugin-<name>` and may prompt
342/// the user (YubiKey touch, Touch ID, PIN entry) via [`UiCallbacks`].
343pub 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    // Hold the plugin object outside the match so the &dyn borrow stays valid.
353    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        // Both recipients can decrypt
423        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    // ── Plugin identity tests ──
448
449    /// Build a syntactically-valid plugin identity + recipient pair for a
450    /// given plugin name. Uses bech32 with dummy entropy — these tests verify
451    /// parsing and dispatch, not plugin interop.
452    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        // age-plugin-yubikey 0.5.1 emits `# Recipient:`, not `# public key:`.
484        // murk must accept it so the native identity file parses unmodified.
485        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        // Native age keys are signing-capable.
503        let (secret, _) = generate_keypair();
504        assert!(parse_identity(&secret).unwrap().is_signing_capable());
505
506        // Plugin/hardware identities cannot sign — no exposed signing scalar.
507        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    // ── New edge-case tests ──
540
541    #[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        // A valid ssh-ed25519 public key (without comment)
562        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        // Unencrypted ed25519 SSH key from age's test suite.
587        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        // The derived pubkey should be parseable as a recipient.
601        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        // Age keypair.
621        let (age_secret, age_pubkey) = generate_keypair();
622
623        // SSH keypair.
624        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        // Encrypt to both.
629        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        // Both can decrypt.
637        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}