Skip to main content

age/ssh/
recipient.rs

1use std::collections::HashSet;
2use std::fmt;
3
4use age_core::{
5    format::{FileKey, Stanza},
6    primitives::{aead_encrypt, hkdf},
7    secrecy::ExposeSecret,
8};
9use base64::{
10    prelude::{BASE64_STANDARD, BASE64_STANDARD_NO_PAD},
11    Engine,
12};
13use curve25519_dalek::edwards::EdwardsPoint;
14use nom::{
15    branch::alt,
16    bytes::streaming::{is_not, tag},
17    combinator::map_opt,
18    sequence::{pair, preceded, separated_pair},
19    IResult, Parser,
20};
21use rand::rngs::OsRng;
22use rsa::{traits::PublicKeyParts, Oaep};
23use sha2::Sha256;
24use x25519_dalek::{EphemeralSecret, PublicKey as X25519PublicKey, StaticSecret};
25
26use super::{
27    identity::{Identity, UnencryptedKey},
28    read_ssh, ssh_tag, EncryptedKey, UnsupportedKey, SSH_ED25519_KEY_PREFIX,
29    SSH_ED25519_RECIPIENT_KEY_LABEL, SSH_ED25519_RECIPIENT_TAG, SSH_RSA_KEY_PREFIX,
30    SSH_RSA_OAEP_LABEL, SSH_RSA_RECIPIENT_TAG,
31};
32use crate::{
33    error::EncryptError,
34    util::read::{encoded_str, str_while_encoded},
35};
36
37/// A key that can be used to encrypt a file to a recipient.
38#[derive(Clone, Debug)]
39pub enum Recipient {
40    /// An ssh-rsa public key.
41    SshRsa(Vec<u8>, rsa::RsaPublicKey),
42    /// An ssh-ed25519 public key.
43    SshEd25519(Vec<u8>, EdwardsPoint),
44}
45
46pub(crate) enum ParsedRecipient {
47    Supported(Recipient),
48    RsaModulusTooLarge,
49    RsaModulusTooSmall,
50    Unsupported(String),
51}
52
53/// Error conditions when parsing an SSH recipient.
54#[derive(Debug, PartialEq, Eq)]
55#[non_exhaustive]
56pub enum ParseRecipientKeyError {
57    /// The string is a parseable value that should be ignored. This case is for handling
58    /// SSH recipient types that may occur in files we want to be able to parse, but that
59    /// we do not directly support.
60    Ignore,
61    /// The string is not a valid SSH recipient.
62    Invalid(&'static str),
63    /// The string is an `ssh-rsa` public key with a modulus larger than we support.
64    RsaModulusTooLarge,
65    /// The string is a weak `ssh-rsa` public key with a modulus smaller than 2048 bits.
66    RsaModulusTooSmall,
67    /// The string is a parseable value that corresponds to an unsupported SSH key type.
68    Unsupported(String),
69}
70
71impl std::str::FromStr for Recipient {
72    type Err = ParseRecipientKeyError;
73
74    /// Parses an SSH recipient from a string.
75    fn from_str(s: &str) -> Result<Self, Self::Err> {
76        match ssh_recipient(rsa::RsaPublicKey::MAX_SIZE)(s) {
77            Ok((_, ParsedRecipient::Supported(pk))) => Ok(pk),
78            Ok((_, ParsedRecipient::RsaModulusTooLarge)) => {
79                Err(ParseRecipientKeyError::RsaModulusTooLarge)
80            }
81            Ok((_, ParsedRecipient::RsaModulusTooSmall)) => {
82                Err(ParseRecipientKeyError::RsaModulusTooSmall)
83            }
84            Ok((_, ParsedRecipient::Unsupported(key_type))) => {
85                Err(ParseRecipientKeyError::Unsupported(key_type))
86            }
87            _ => Err(ParseRecipientKeyError::Invalid("invalid SSH recipient")),
88        }
89    }
90}
91
92impl fmt::Display for Recipient {
93    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
94        match self {
95            Recipient::SshRsa(ssh_key, _) => {
96                write!(
97                    f,
98                    "{} {}",
99                    SSH_RSA_KEY_PREFIX,
100                    BASE64_STANDARD.encode(ssh_key)
101                )
102            }
103            Recipient::SshEd25519(ssh_key, _) => {
104                write!(
105                    f,
106                    "{} {}",
107                    SSH_ED25519_KEY_PREFIX,
108                    BASE64_STANDARD.encode(ssh_key)
109                )
110            }
111        }
112    }
113}
114
115impl TryFrom<Identity> for Recipient {
116    type Error = ParseRecipientKeyError;
117
118    fn try_from(identity: Identity) -> Result<Self, Self::Error> {
119        match identity {
120            Identity::Unencrypted(UnencryptedKey::SshRsa(ssh_key, _))
121            | Identity::Unencrypted(UnencryptedKey::SshEd25519(ssh_key, _))
122            | Identity::Encrypted(EncryptedKey { ssh_key, .. }) => {
123                if let Ok((_, pk)) = read_ssh::rsa_pubkey(rsa::RsaPublicKey::MAX_SIZE)(&ssh_key) {
124                    if let Some(pk) = pk {
125                        Ok(Recipient::SshRsa(ssh_key, pk))
126                    } else {
127                        Err(ParseRecipientKeyError::RsaModulusTooLarge)
128                    }
129                } else if let Ok((_, pk)) = read_ssh::ed25519_pubkey(&ssh_key) {
130                    Ok(Recipient::SshEd25519(ssh_key, pk))
131                } else if let Ok((_, key_type)) = read_ssh::string(&ssh_key) {
132                    Err(ParseRecipientKeyError::Unsupported(
133                        String::from_utf8_lossy(key_type).to_string(),
134                    ))
135                } else {
136                    Err(ParseRecipientKeyError::Invalid(
137                        "Invalid SSH pubkey in SSH privkey",
138                    ))
139                }
140            }
141            Identity::Unsupported(
142                UnsupportedKey::Hardware(key_type) | UnsupportedKey::Type(key_type),
143            ) => Err(ParseRecipientKeyError::Unsupported(key_type)),
144            Identity::Unsupported(_) => Err(ParseRecipientKeyError::Ignore),
145        }
146    }
147}
148
149impl crate::Recipient for Recipient {
150    fn wrap_file_key(
151        &self,
152        file_key: &FileKey,
153    ) -> Result<(Vec<Stanza>, HashSet<String>), EncryptError> {
154        let mut rng = OsRng;
155
156        let stanzas = match self {
157            Recipient::SshRsa(ssh_key, pk) => {
158                let encrypted_file_key = pk
159                    .encrypt(
160                        &mut rng,
161                        Oaep::new_with_label::<Sha256, _>(SSH_RSA_OAEP_LABEL),
162                        file_key.expose_secret(),
163                    )
164                    .expect("pubkey is valid and file key is not too long");
165
166                let encoded_tag = BASE64_STANDARD_NO_PAD.encode(ssh_tag(ssh_key));
167
168                vec![Stanza {
169                    tag: SSH_RSA_RECIPIENT_TAG.to_owned(),
170                    args: vec![encoded_tag],
171                    body: encrypted_file_key,
172                }]
173            }
174            Recipient::SshEd25519(ssh_key, ed25519_pk) => {
175                let pk: X25519PublicKey = ed25519_pk.to_montgomery().to_bytes().into();
176
177                let esk = EphemeralSecret::random_from_rng(rng);
178                let epk: X25519PublicKey = (&esk).into();
179
180                let tweak: StaticSecret =
181                    hkdf(ssh_key, SSH_ED25519_RECIPIENT_KEY_LABEL, &[]).into();
182                let shared_secret =
183                    tweak.diffie_hellman(&(*esk.diffie_hellman(&pk).as_bytes()).into());
184
185                let mut salt = [0; 64];
186                salt[..32].copy_from_slice(epk.as_bytes());
187                salt[32..].copy_from_slice(pk.as_bytes());
188
189                let enc_key = hkdf(
190                    &salt,
191                    SSH_ED25519_RECIPIENT_KEY_LABEL,
192                    shared_secret.as_bytes(),
193                );
194                let encrypted_file_key = aead_encrypt(&enc_key, file_key.expose_secret());
195
196                let encoded_tag = BASE64_STANDARD_NO_PAD.encode(ssh_tag(ssh_key));
197                let encoded_epk = BASE64_STANDARD_NO_PAD.encode(epk.as_bytes());
198
199                vec![Stanza {
200                    tag: SSH_ED25519_RECIPIENT_TAG.to_owned(),
201                    args: vec![encoded_tag, encoded_epk],
202                    body: encrypted_file_key,
203                }]
204            }
205        };
206
207        Ok((stanzas, HashSet::new()))
208    }
209}
210
211fn ssh_rsa_pubkey(max_size: usize) -> impl Fn(&str) -> IResult<&str, ParsedRecipient> {
212    move |input: &str| {
213        preceded(
214            pair(tag(SSH_RSA_KEY_PREFIX), tag(" ")),
215            map_opt(
216                str_while_encoded(BASE64_STANDARD_NO_PAD),
217                |ssh_key| match read_ssh::rsa_pubkey(max_size)(&ssh_key) {
218                    Ok((_, Some(pk))) => Some(if pk.n().bits() < 2048 {
219                        ParsedRecipient::RsaModulusTooSmall
220                    } else {
221                        ParsedRecipient::Supported(Recipient::SshRsa(ssh_key, pk))
222                    }),
223                    Ok((_, None)) => Some(ParsedRecipient::RsaModulusTooLarge),
224                    Err(_) => None,
225                },
226            ),
227        )
228        .parse(input)
229    }
230}
231
232fn ssh_ed25519_pubkey(input: &str) -> IResult<&str, ParsedRecipient> {
233    preceded(
234        pair(tag(SSH_ED25519_KEY_PREFIX), tag(" ")),
235        map_opt(
236            encoded_str(51, BASE64_STANDARD_NO_PAD),
237            |ssh_key| match read_ssh::ed25519_pubkey(&ssh_key) {
238                Ok((_, pk)) => Some(ParsedRecipient::Supported(Recipient::SshEd25519(
239                    ssh_key, pk,
240                ))),
241                Err(_) => None,
242            },
243        ),
244    )
245    .parse(input)
246}
247
248fn ssh_ignore_pubkey(input: &str) -> IResult<&str, ParsedRecipient> {
249    // We rely on the invariant that SSH public keys are always of the form
250    // `key_type Base64(string(key_type) || ...)` to detect valid pubkeys.
251    map_opt(
252        separated_pair(
253            is_not(" "),
254            tag(" "),
255            str_while_encoded(BASE64_STANDARD_NO_PAD),
256        ),
257        |(key_type, ssh_key)| {
258            read_ssh::string_tag(key_type)(&ssh_key)
259                .map(|_| ParsedRecipient::Unsupported(key_type.to_string()))
260                .ok()
261        },
262    )
263    .parse(input)
264}
265
266pub(crate) fn ssh_recipient(max_size: usize) -> impl Fn(&str) -> IResult<&str, ParsedRecipient> {
267    move |input| {
268        alt((
269            ssh_rsa_pubkey(max_size),
270            ssh_ed25519_pubkey,
271            ssh_ignore_pubkey,
272        ))
273        .parse(input)
274    }
275}
276
277#[cfg(test)]
278pub(crate) mod tests {
279    use super::{ParseRecipientKeyError, Recipient};
280
281    pub(crate) const TEST_SSH_RSA_PK: &str = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDE7nIXTGNuaRBN9toI/wNALuQec8mvlt0iJ7o3OaD2UvoKHJ7S8rmIn4FiQDUed/Vac3OhUibei1k+TBmm16u2Rj3klgWZOIDgi8d4vXKI5N3YBhxr3jsQ+kz1c+iZ4z/tTtz306+4K46XViVMWwyyg9j82Jn41mOAy9vdeDIfQ5fLeaGqn5KwlT61GNkZ+ozWK/ZNlQIlNCcoXxhJULIs9XrtczWyVBAea1nlDo0WHODePxoJjmsNHrpQXn5mf9O83xs10qfTUjnRUt48jRmedFy4tcra3QGmSTQ3KZne+wXXSb0cIpXLGvZjQSPHgG1hc4r3uBpiSzvesGLv79XL alice@rust";
282    pub(crate) const TEST_SSH_ED25519_PK: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHsKLqeplhpW+uObz5dvMgjz1OxfM/XXUB+VHtZ6isGN alice@rust";
283    const TEST_SSH_UNSUPPORTED_PK: &str = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBHFliOyIZs1gxGF3fmDxFykQhE88wy6AKDGFBfn0R6ZuvRmENABZQa9+pj9hMki+LX0qDJbmHTiWDbYv/cmFt/Q=";
284    const TEST_SSH_INVALID_PK: &str = "ecdsa-sha2-nistp256 AAAAC3NzaC1lZDI1NTE5AAAAIHsKLqeplhpW+uObz5dvMgjz1OxfM/XXUB+VHtZ6isGN alice@rust";
285
286    #[test]
287    fn ssh_rsa_encoding() {
288        let pk: Recipient = TEST_SSH_RSA_PK.parse().unwrap();
289        assert_eq!(pk.to_string() + " alice@rust", TEST_SSH_RSA_PK);
290    }
291
292    #[test]
293    fn ssh_ed25519_encoding() {
294        let pk: Recipient = TEST_SSH_ED25519_PK.parse().unwrap();
295        assert_eq!(pk.to_string() + " alice@rust", TEST_SSH_ED25519_PK);
296    }
297
298    #[test]
299    fn ssh_unsupported_key_type() {
300        let pk: Result<Recipient, ParseRecipientKeyError> = TEST_SSH_UNSUPPORTED_PK.parse();
301        assert_eq!(
302            pk.unwrap_err(),
303            ParseRecipientKeyError::Unsupported("ecdsa-sha2-nistp256".to_string()),
304        );
305    }
306
307    #[test]
308    fn ssh_invalid_encoding() {
309        let pk: Result<Recipient, ParseRecipientKeyError> = TEST_SSH_INVALID_PK.parse();
310        assert_eq!(
311            pk.unwrap_err(),
312            ParseRecipientKeyError::Invalid("invalid SSH recipient")
313        );
314    }
315}