age/ssh/
identity.rs

1use age_core::{
2    format::{FileKey, Stanza, FILE_KEY_BYTES},
3    primitives::{aead_decrypt, hkdf},
4    secrecy::{ExposeSecret, SecretBox},
5};
6use base64::prelude::BASE64_STANDARD;
7use nom::{
8    branch::alt,
9    bytes::streaming::{is_not, tag},
10    character::streaming::{line_ending, newline},
11    combinator::{map_opt, opt},
12    sequence::{pair, preceded, terminated, tuple},
13    IResult,
14};
15use rand::rngs::OsRng;
16use rsa::{pkcs1::DecodeRsaPrivateKey, Oaep};
17use sha2::{Digest, Sha256, Sha512};
18use std::fmt;
19use std::io;
20use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
21use zeroize::Zeroize;
22
23use super::{
24    read_ssh, ssh_tag, write_ssh, EncryptedKey, SSH_ED25519_RECIPIENT_KEY_LABEL,
25    SSH_ED25519_RECIPIENT_TAG, SSH_RSA_OAEP_LABEL, SSH_RSA_RECIPIENT_TAG, TAG_LEN_BYTES,
26};
27use crate::{
28    error::DecryptError,
29    fl,
30    util::read::{base64_arg, wrapped_str_while_encoded},
31    wfl, wlnfl, Callbacks,
32};
33
34/// An SSH private key for decrypting an age file.
35pub enum UnencryptedKey {
36    /// An ssh-rsa private key.
37    SshRsa(Vec<u8>, Box<rsa::RsaPrivateKey>),
38    /// An ssh-ed25519 key pair.
39    SshEd25519(Vec<u8>, SecretBox<[u8; 64]>),
40}
41
42impl Clone for UnencryptedKey {
43    fn clone(&self) -> Self {
44        match self {
45            Self::SshRsa(ssh_key, sk) => Self::SshRsa(ssh_key.clone(), sk.clone()),
46            Self::SshEd25519(ssh_key, privkey) => Self::SshEd25519(
47                ssh_key.clone(),
48                SecretBox::new({
49                    let mut cloned_privkey = Box::new([0; 64]);
50                    cloned_privkey.copy_from_slice(privkey.expose_secret());
51                    cloned_privkey
52                }),
53            ),
54        }
55    }
56}
57
58impl UnencryptedKey {
59    /// Returns:
60    /// - `Some(Ok(file_key))` on success.
61    /// - `Some(Err(e))` if a decryption error occurs.
62    /// - `None` if the [`Stanza`] does not match this key.
63    pub(crate) fn unwrap_stanza(&self, stanza: &Stanza) -> Option<Result<FileKey, DecryptError>> {
64        match (self, stanza.tag.as_str()) {
65            (UnencryptedKey::SshRsa(ssh_key, sk), SSH_RSA_RECIPIENT_TAG) => {
66                let tag = base64_arg::<_, TAG_LEN_BYTES, 6>(stanza.args.get(0)?)?;
67                if ssh_tag(ssh_key) != tag {
68                    return None;
69                }
70
71                let mut rng = OsRng;
72
73                // A failure to decrypt is fatal, because we assume that we won't
74                // encounter 32-bit collisions on the key tag embedded in the header.
75                Some(
76                    sk.decrypt_blinded(
77                        &mut rng,
78                        Oaep::new_with_label::<Sha256, _>(SSH_RSA_OAEP_LABEL),
79                        &stanza.body,
80                    )
81                    .map_err(DecryptError::from)
82                    .and_then(|mut pt| {
83                        // It's ours!
84                        FileKey::try_init_with_mut(|file_key| {
85                            let ret = if pt.len() == file_key.len() {
86                                file_key.copy_from_slice(&pt);
87                                Ok(())
88                            } else {
89                                Err(DecryptError::DecryptionFailed)
90                            };
91                            pt.zeroize();
92                            ret
93                        })
94                    }),
95                )
96            }
97            (UnencryptedKey::SshEd25519(ssh_key, privkey), SSH_ED25519_RECIPIENT_TAG) => {
98                let tag = base64_arg::<_, TAG_LEN_BYTES, 6>(stanza.args.get(0)?)?;
99                if ssh_tag(ssh_key) != tag {
100                    return None;
101                }
102                if stanza.body.len() != crate::x25519::ENCRYPTED_FILE_KEY_BYTES {
103                    return Some(Err(DecryptError::InvalidHeader));
104                }
105
106                let epk =
107                    base64_arg::<_, { crate::x25519::EPK_LEN_BYTES }, 33>(stanza.args.get(1)?)?
108                        .into();
109
110                let sk: StaticSecret = {
111                    let mut sk = [0; 32];
112                    // privkey format is seed || pubkey
113                    sk.copy_from_slice(&Sha512::digest(&privkey.expose_secret()[0..32])[0..32]);
114                    sk.into()
115                };
116                let pk = X25519PublicKey::from(&sk);
117
118                let tweak: StaticSecret =
119                    hkdf(ssh_key, SSH_ED25519_RECIPIENT_KEY_LABEL, &[]).into();
120                let shared_secret = tweak
121                    .diffie_hellman(&X25519PublicKey::from(*sk.diffie_hellman(&epk).as_bytes()));
122
123                let mut salt = [0; 64];
124                salt[..32].copy_from_slice(epk.as_bytes());
125                salt[32..].copy_from_slice(pk.as_bytes());
126
127                let enc_key = hkdf(
128                    &salt,
129                    SSH_ED25519_RECIPIENT_KEY_LABEL,
130                    shared_secret.as_bytes(),
131                );
132
133                // A failure to decrypt is fatal, because we assume that we won't
134                // encounter 32-bit collisions on the key tag embedded in the header.
135                Some(
136                    aead_decrypt(&enc_key, FILE_KEY_BYTES, &stanza.body)
137                        .map_err(DecryptError::from)
138                        .map(|mut pt| {
139                            // It's ours!
140                            FileKey::init_with_mut(|file_key| {
141                                file_key.copy_from_slice(&pt);
142                                pt.zeroize();
143                            })
144                        }),
145                )
146            }
147            _ => None,
148        }
149    }
150}
151
152/// A key that we know how to parse, but that we do not support.
153///
154/// The Display impl provides details for each unsupported key as to why we don't support
155/// it, and how a user can migrate to a supported key.
156#[derive(Clone, Debug, PartialEq, Eq)]
157pub enum UnsupportedKey {
158    /// An encrypted `PEM` key.
159    EncryptedPem,
160    /// An encrypted SSH key using a specific cipher.
161    EncryptedSsh(String),
162    /// An SSH key type we believe to be stored on a hardware security key.
163    Hardware(String),
164    /// An SSH key type that we do not support.
165    Type(String),
166}
167
168impl UnsupportedKey {
169    pub(crate) fn from_key_type(key_type: String) -> Self {
170        if key_type.starts_with("sk-ssh-") {
171            Self::Hardware(key_type)
172        } else {
173            Self::Type(key_type)
174        }
175    }
176
177    /// Prints details about this unsupported key.
178    pub fn display(&self, f: &mut fmt::Formatter, filename: Option<&str>) -> fmt::Result {
179        if let Some(name) = filename {
180            wlnfl!(f, "ssh-unsupported-key", name = name)?;
181            writeln!(f)?;
182        }
183        match self {
184            UnsupportedKey::EncryptedPem => wfl!(
185                f,
186                "ssh-insecure-key-format",
187                change_passphrase = "ssh-keygen -o -p",
188                gen_new = "ssh-keygen -o",
189            )?,
190            UnsupportedKey::EncryptedSsh(cipher) => {
191                let new_issue = format!(
192                    "https://github.com/str4d/rage/issues/new?title=Support%20OpenSSH%20key%20encryption%20cipher%20{}",
193                    cipher,
194                );
195                wfl!(
196                    f,
197                    "ssh-unsupported-cipher",
198                    cipher = cipher.as_str(),
199                    new_issue = new_issue.as_str(),
200                )?;
201            }
202            UnsupportedKey::Hardware(key_type) => wfl!(
203                f,
204                "ssh-unsupported-security-key",
205                key_type = key_type.as_str(),
206                age_plugin_yubikey_url = "https://str4d.xyz/age-plugin-yubikey",
207            )?,
208            UnsupportedKey::Type(key_type) => {
209                wfl!(f, "ssh-unsupported-key-type", key_type = key_type.as_str())?
210            }
211        }
212        Ok(())
213    }
214}
215
216/// An SSH private key for decrypting an age file.
217#[derive(Clone)]
218pub enum Identity {
219    /// An unencrypted key.
220    Unencrypted(UnencryptedKey),
221    /// An encrypted key.
222    Encrypted(EncryptedKey),
223    /// A key that we know how to parse, but that we do not support.
224    Unsupported(UnsupportedKey),
225}
226
227impl From<UnencryptedKey> for Identity {
228    fn from(key: UnencryptedKey) -> Self {
229        Identity::Unencrypted(key)
230    }
231}
232
233impl From<EncryptedKey> for Identity {
234    fn from(key: EncryptedKey) -> Self {
235        Identity::Encrypted(key)
236    }
237}
238
239impl From<UnsupportedKey> for Identity {
240    fn from(key: UnsupportedKey) -> Self {
241        Identity::Unsupported(key)
242    }
243}
244
245impl Identity {
246    /// Parses one or more identities from a buffered input containing valid UTF-8.
247    ///
248    /// `filename` is the path to the file that the input is reading from, if any.
249    pub fn from_buffer<R: io::BufRead>(mut data: R, filename: Option<String>) -> io::Result<Self> {
250        let mut buf = String::new();
251        loop {
252            match ssh_identity(&buf) {
253                Ok((_, mut identity)) => {
254                    // If we know the filename, cache it.
255                    if let Identity::Encrypted(key) = &mut identity {
256                        key.filename = filename;
257                    }
258
259                    break Ok(identity);
260                }
261                Err(nom::Err::Incomplete(nom::Needed::Size(_))) => {
262                    if data.read_line(&mut buf)? == 0 {
263                        break Err(io::Error::new(
264                            io::ErrorKind::Interrupted,
265                            "incomplete SSH identity in file",
266                        ));
267                    };
268                }
269                Err(_) => {
270                    break Err(io::Error::new(
271                        io::ErrorKind::InvalidData,
272                        "invalid SSH identity",
273                    ));
274                }
275            }
276        }
277    }
278
279    /// Wraps this identity with the provided callbacks, so that if this is an encrypted
280    /// identity, it can potentially be decrypted.
281    pub fn with_callbacks<C: Callbacks>(self, callbacks: C) -> impl crate::Identity {
282        DecryptableIdentity {
283            identity: self,
284            callbacks,
285        }
286    }
287}
288
289impl crate::Identity for Identity {
290    fn unwrap_stanza(&self, stanza: &Stanza) -> Option<Result<FileKey, DecryptError>> {
291        match self {
292            Identity::Unencrypted(key) => key.unwrap_stanza(stanza),
293            Identity::Encrypted(_) | Identity::Unsupported(_) => None,
294        }
295    }
296}
297
298struct DecryptableIdentity<C: Callbacks> {
299    identity: Identity,
300    callbacks: C,
301}
302
303impl<C: Callbacks> crate::Identity for DecryptableIdentity<C> {
304    fn unwrap_stanza(&self, stanza: &Stanza) -> Option<Result<FileKey, DecryptError>> {
305        match &self.identity {
306            Identity::Unencrypted(key) => key.unwrap_stanza(stanza),
307            Identity::Encrypted(enc) => {
308                let passphrase = self.callbacks.request_passphrase(&fl!(
309                    "ssh-passphrase-prompt",
310                    filename = enc.filename.as_deref().unwrap_or_default()
311                ))?;
312                let decrypted = match enc.decrypt(passphrase) {
313                    Ok(d) => d,
314                    Err(e) => return Some(Err(e)),
315                };
316                decrypted.unwrap_stanza(stanza)
317            }
318            Identity::Unsupported(_) => None,
319        }
320    }
321}
322
323fn rsa_pem_encryption_header(input: &str) -> IResult<&str, &str> {
324    preceded(
325        tuple((tag("Proc-Type: 4,ENCRYPTED"), newline, tag("DEK-Info: "))),
326        terminated(is_not("\n"), newline),
327    )(input)
328}
329
330fn rsa_privkey(input: &str) -> IResult<&str, Identity> {
331    preceded(
332        pair(tag("-----BEGIN RSA PRIVATE KEY-----"), line_ending),
333        terminated(
334            map_opt(
335                pair(
336                    opt(terminated(rsa_pem_encryption_header, line_ending)),
337                    wrapped_str_while_encoded(BASE64_STANDARD),
338                ),
339                |(enc_header, privkey)| {
340                    if enc_header.is_some() {
341                        Some(UnsupportedKey::EncryptedPem.into())
342                    } else {
343                        rsa::RsaPrivateKey::from_pkcs1_der(&privkey)
344                            .ok()
345                            .map(|privkey| {
346                                let mut ssh_key = vec![];
347                                cookie_factory::gen(
348                                    write_ssh::rsa_pubkey(&privkey.to_public_key()),
349                                    &mut ssh_key,
350                                )
351                                .expect("can write into a Vec");
352                                UnencryptedKey::SshRsa(ssh_key, Box::new(privkey)).into()
353                            })
354                    }
355                },
356            ),
357            pair(line_ending, tag("-----END RSA PRIVATE KEY-----")),
358        ),
359    )(input)
360}
361
362fn openssh_privkey(input: &str) -> IResult<&str, Identity> {
363    preceded(
364        pair(tag("-----BEGIN OPENSSH PRIVATE KEY-----"), line_ending),
365        terminated(
366            map_opt(wrapped_str_while_encoded(BASE64_STANDARD), |privkey| {
367                read_ssh::openssh_privkey(&privkey).ok().map(|(_, key)| key)
368            }),
369            pair(line_ending, tag("-----END OPENSSH PRIVATE KEY-----")),
370        ),
371    )(input)
372}
373
374pub(crate) fn ssh_identity(input: &str) -> IResult<&str, Identity> {
375    alt((rsa_privkey, openssh_privkey))(input)
376}
377
378#[cfg(test)]
379pub(crate) mod tests {
380    use age_core::{
381        format::FileKey,
382        secrecy::{ExposeSecret, SecretString},
383    };
384    use std::io::BufReader;
385
386    use super::{Identity, UnsupportedKey};
387    use crate::{
388        ssh::recipient::{
389            tests::{TEST_SSH_ED25519_PK, TEST_SSH_RSA_PK},
390            Recipient,
391        },
392        Callbacks, Identity as _, Recipient as _,
393    };
394
395    pub(crate) const TEST_SSH_RSA_SK: &str = "-----BEGIN RSA PRIVATE KEY-----
396MIIEogIBAAKCAQEAxO5yF0xjbmkQTfbaCP8DQC7kHnPJr5bdIie6Nzmg9lL6Chye
3970vK5iJ+BYkA1Hnf1WnNzoVIm3otZPkwZptertkY95JYFmTiA4IvHeL1yiOTd2AYc
398a947EPpM9XPomeM/7U7c99OvuCuOl1YlTFsMsoPY/NiZ+NZjgMvb3XgyH0OXy3mh
399qp+SsJU+tRjZGfqM1iv2TZUCJTQnKF8YSVCyLPV67XM1slQQHmtZ5Q6NFhzg3j8a
400CY5rDR66UF5+Zn/TvN8bNdKn01I50VLePI0ZnnRcuLXK2t0Bpkk0NymZ3vsF10m9
401HCKVyxr2Y0Ejx4BtYXOK97gaYks73rBi7+/VywIDAQABAoIBADGsf8TWtOH9yGoS
402ES9hu90ttsbjqAUNhdv+r18Mv0hC5+UzEPDe3uPScB1rWrrDwXS+WHVhtoI+HhWz
403tmi6UArbLvOA0Aq1EPUS7Q7Mop5bNIYwDG09EiMXL+BeC1b91nsygFRW5iULf502
4040pOvB8XjshEdRcFZuqGbSmtTzTjLLxYS/aboBtZLHrH4cRlFMpHWCSuJng8Psahp
405SnJbkjL7fHG81dlH+M3qm5EwdDJ1UmNkBfoSfGRs2pupk2cSJaL+SPkvNX+6Xyoy
406yvfnbJzKUTcV6rf+0S0P0yrWK3zRK9maPJ1N60lFui9LvFsunCLkSAluGKiMwEjb
407fm40F4kCgYEA+QzIeIGMwnaOQdAW4oc7hX5MgRPXJ836iALy56BCkZpZMjZ+VKpk
4088P4E1HrEywpgqHMox08hfCTGX3Ph6fFIlS1/mkLojcgkrqmg1IrRvh8vvaZqzaAf
409GKEhxxRta9Pvm44E2nUY97iCKzE3Vfh+FIyQLRuc+0COu49Me4HPtBUCgYEAym1T
410vNZKPfC/eTMh+MbWMsQArOePdoHQyRC38zeWrLaDFOUVzwzEvCQ0IzSs0PnLWkZ4
411xx60wBg5ZdU4iH4cnOYgjavQrbRFrCmZ1KDUm2+NAMw3avcLQqu41jqzyAlkktUL
412fZzyqHIBmKYLqut5GslkGnQVg6hB4psutHhiel8CgYA3yy9WH9/C6QBxqgaWdSlW
413fLby69j1p+WKdu6oCXUgXW3CHActPIckniPC3kYcHpUM58+o5wdfYnW2iKWB3XYf
414RXQiwP6MVNwy7PmE5Byc9Sui1xdyPX75648/pEnnMDGrraNUtYsEZCd1Oa9l6SeF
415vv/Fuzvt5caUKkQ+HxTDCQKBgFhqUiXr7zeIvQkiFVeE+a/ovmbHKXlYkCoSPFZm
416VFCR00VAHjt2V0PaCE/MRSNtx61hlIVcWxSAQCnDbNLpSnQZa+SVRCtqzve4n/Eo
417YlSV75+GkzoMN4XiXXRs5XOc7qnXlhJCiBac3Segdv4rpZTWm/uV8oOz7TseDtNS
418tai/AoGAC0CiIJAzmmXscXNS/stLrL9bb3Yb+VZi9zN7Cb/w7B0IJ35N5UOFmKWA
419QIGpMU4gh6p52S1eLttpIf2+39rEDzo8pY6BVmEp3fKN3jWmGS4mJQ31tWefupC+
420fGNu+wyKxPnSU3svsuvrOdwwDKvfqCNyYK878qKAAaBqbGT1NJ8=
421-----END RSA PRIVATE KEY-----";
422
423    /// The same SSH key either unencrypted or encrypted with the passphrase "passphrase".
424    const TEST_SSH_ED25519_SK_LIST: &[(&str, &str)] = &[
425        (
426            "none",
427            "-----BEGIN OPENSSH PRIVATE KEY-----
428b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
429QyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQAAAJCfEwtqnxML
430agAAAAtzc2gtZWQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQ
431AAAEADBJvjZT8X6JRJI8xVq/1aU8nMVgOtVnmdwqWwrSlXG3sKLqeplhpW+uObz5dvMgjz
4321OxfM/XXUB+VHtZ6isGNAAAADHN0cjRkQGNhcmJvbgE=
433-----END OPENSSH PRIVATE KEY-----",
434        ),
435        (
436            "aes256-cbc",
437            "-----BEGIN OPENSSH PRIVATE KEY-----
438b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jYmMAAAAGYmNyeXB0AAAAGAAAABC0OgNmiw
439QW/kJ8kCmmTA2TAAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIHsKLqeplhpW+uOb
440z5dvMgjz1OxfM/XXUB+VHtZ6isGNAAAAkPhBKsZoNmaeuWYJQxOl+ofEmue/sFJnW+4IOt
441oTrS/orMBJ4b/phQcv/ejWYJ4RYYVhSLiI6hf0KwNGefxI90E8iG/yDOKcrxb34tqDEYrY
442FARDaJVRd9QtWLEqoP7pgdBR2BTP7aK1y6Mx3eFDgiQI9f/0Sjxd8V0apOPXv4i4kuQ1Nt
443LF7kNlDznn/nyZlg==
444-----END OPENSSH PRIVATE KEY-----",
445        ),
446        (
447            "aes128-ctr",
448            "-----BEGIN OPENSSH PRIVATE KEY-----
449b3BlbnNzaC1rZXktdjEAAAAACmFlczEyOC1jdHIAAAAGYmNyeXB0AAAAGAAAABBub+J2jZ
450gyLfNBpxN08TqrAAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIHsKLqeplhpW+uOb
451z5dvMgjz1OxfM/XXUB+VHtZ6isGNAAAAkLXOo/xKLiv8ToPkQ9l838+Lps5NAkJ/dnJLt9
452134yXn7q/7DLtsbc6KesgELApQ3Niwirqom+GwDiuNra8/JspF6iz9HZHPjFvdCLQkpQnZ
453eB6tzoh6FNmfP2HlQjmJ2w0dNMov4/0PKSAYOnW7kXq0Li/E/Gxju/raMa+pU5guk2B93v
454D/wSEe2BjjIuXZ8g==
455-----END OPENSSH PRIVATE KEY-----",
456        ),
457        (
458            "aes192-ctr",
459            "-----BEGIN OPENSSH PRIVATE KEY-----
460b3BlbnNzaC1rZXktdjEAAAAACmFlczE5Mi1jdHIAAAAGYmNyeXB0AAAAGAAAABCQRxCxO3
461qnd3DPzT+ICJvfAAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIHsKLqeplhpW+uOb
462z5dvMgjz1OxfM/XXUB+VHtZ6isGNAAAAkIZMU3zFGbvSR/gvmNd9qiKr+/XCxgE3NOCrWe
463dIAveOwKzR4eXNO94TN4FF6iZv5USO1m4Mjbn3jiW4pSB6lnfctOCBWR6QPtssH0ZrmXMW
464OeOG1Nmlj2FG8LmfVNNrZ9JnXVrQYNqbvkxShb90DEFJwHWRCpzXIJEUepFJPyUPB+xLAm
465QMSqncd3IdGNmcQQ==
466-----END OPENSSH PRIVATE KEY-----",
467        ),
468        (
469            "aes256-ctr",
470            "-----BEGIN OPENSSH PRIVATE KEY-----
471b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABBSs0SUhQ
472958xWERf6ibyf2AAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIHsKLqeplhpW+uOb
473z5dvMgjz1OxfM/XXUB+VHtZ6isGNAAAAkLvH9UsJa+ulewsZT2YtEkme1y9UZKI/vUbTms
474LVqWdLprBQIm3IClfGso6IPW7+imkwYRHPKYfBYGYuexzO8b+LRiZU5/lDQmsvZA3asNxp
475KjW7kUOJnI8dAeaqJa18P7XkAuzcuZmVoCTurqEOSeb5Ww9Nq0csB0zkF22/PeWy3+BZW5
476hDsL1OfQl4WbakZQ==
477-----END OPENSSH PRIVATE KEY-----",
478        ),
479        (
480            "aes256-gcm@openssh.com",
481            "-----BEGIN OPENSSH PRIVATE KEY-----
482b3BlbnNzaC1rZXktdjEAAAAAFmFlczI1Ni1nY21Ab3BlbnNzaC5jb20AAAAGYmNyeXB0AA
483AAGAAAABCPl8ey+kOWEfNDWjsOW+yeAAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAA
484IHsKLqeplhpW+uObz5dvMgjz1OxfM/XXUB+VHtZ6isGNAAAAkHK4lAYpbPto7eVDnl7RM5
485smu3f1Gi/Ov305gASYkCWxL3cvzxTgP2prG7ky4FS5EnFeCoZU4GR49nMjTtJwVJz9vUmQ
486csGgRF9XqsdNcNwroWoIeejitFjrQ/n+zVreeMtCWU3gvVSHV97ZhcBVCxCQyPdeaQoUr9
487k38nvmwdar9EY4Mb7LrSqR6oybE/g9Hjg6cxzVcvDQKga6tJVM5oY=
488-----END OPENSSH PRIVATE KEY-----",
489        ),
490    ];
491    pub(crate) const TEST_SSH_ED25519_SK: &str = TEST_SSH_ED25519_SK_LIST[0].1;
492
493    pub(crate) const TEST_SSH_ECDSA_SK: &str = "-----BEGIN OPENSSH PRIVATE KEY-----
494b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS
4951zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQQQ0odKVFtwOmuCl6RXfwzExGs9dP9a
496V9H5xAfETILMd7sLFgqyOxz1FA84EZV0vKdW5c0HPB7/JxQw0vFmNSWeAAAAqGOGFFJjhh
497RSAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBBDSh0pUW3A6a4KX
498pFd/DMTEaz10/1pX0fnEB8RMgsx3uwsWCrI7HPUUDzgRlXS8p1blzQc8Hv8nFDDS8WY1JZ
4994AAAAgBQ5LA+stpdk3TYwB/4xhiOaDHzxaacv+u47ciigD8bQAAAAKc3RyNGRAY3ViZQEC
500AwQFBg==
501-----END OPENSSH PRIVATE KEY-----";
502
503    #[derive(Clone)]
504    struct TestPassphrase(&'static str);
505
506    impl Callbacks for TestPassphrase {
507        fn display_message(&self, _: &str) {
508            unimplemented!()
509        }
510
511        fn confirm(&self, _: &str, _: &str, _: Option<&str>) -> Option<bool> {
512            unimplemented!()
513        }
514
515        fn request_public_string(&self, _: &str) -> Option<String> {
516            unimplemented!()
517        }
518
519        fn request_passphrase(&self, _: &str) -> Option<SecretString> {
520            Some(SecretString::from(self.0.to_owned()))
521        }
522    }
523
524    #[test]
525    fn ssh_rsa_round_trip() {
526        let buf = BufReader::new(TEST_SSH_RSA_SK.as_bytes());
527        let identity = Identity::from_buffer(buf, None).unwrap();
528        match &identity {
529            Identity::Unencrypted(_) => (),
530            _ => panic!("key should be unencrypted"),
531        };
532        let pk: Recipient = TEST_SSH_RSA_PK.parse().unwrap();
533
534        let file_key = FileKey::new(Box::new([12; 16]));
535
536        let (wrapped, labels) = pk.wrap_file_key(&file_key).unwrap();
537        assert!(labels.is_empty());
538        let unwrapped = identity.unwrap_stanzas(&wrapped);
539        assert_eq!(
540            unwrapped.unwrap().unwrap().expose_secret(),
541            file_key.expose_secret()
542        );
543    }
544
545    #[test]
546    fn ssh_ed25519_round_trip() {
547        for (kind, sk) in TEST_SSH_ED25519_SK_LIST {
548            eprintln!("Testing cipher '{}'", kind);
549            let buf = BufReader::new(sk.as_bytes());
550            let identity = Identity::from_buffer(buf, None).unwrap();
551            match (*kind, &identity) {
552                ("none", Identity::Unencrypted(_)) => (),
553                ("none", _) => panic!("key should be unencrypted"),
554                (_, Identity::Encrypted(_)) => (),
555                (_, Identity::Unsupported(_)) => panic!("{} cipher is unsupported", kind),
556                (_, _) => panic!("key should be encrypted"),
557            };
558            let identity = identity.with_callbacks(TestPassphrase("passphrase"));
559            let pk: Recipient = TEST_SSH_ED25519_PK.parse().unwrap();
560
561            let file_key = FileKey::new(Box::new([12; 16]));
562
563            let (wrapped, labels) = pk.wrap_file_key(&file_key).unwrap();
564            assert!(labels.is_empty());
565            let unwrapped = identity.unwrap_stanzas(&wrapped);
566            assert_eq!(
567                unwrapped.unwrap().unwrap().expose_secret(),
568                file_key.expose_secret()
569            );
570        }
571    }
572
573    #[test]
574    fn ssh_unsupported_key_type() {
575        let buf = BufReader::new(TEST_SSH_ECDSA_SK.as_bytes());
576        let identity = Identity::from_buffer(buf, None).unwrap();
577        let unsupported = match &identity {
578            Identity::Unsupported(res) => res,
579            _ => panic!("key should be unencrypted"),
580        };
581        assert_eq!(
582            unsupported,
583            &UnsupportedKey::Type("ecdsa-sha2-nistp256".to_string()),
584        );
585    }
586}