age/
x25519.rs

1//! The "x25519" recipient type, native to age.
2
3use std::collections::HashSet;
4use std::fmt;
5
6use age_core::{
7    format::{FileKey, Stanza, FILE_KEY_BYTES},
8    primitives::{aead_decrypt, aead_encrypt, hkdf},
9    secrecy::{ExposeSecret, SecretString},
10};
11use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
12use bech32::{ToBase32, Variant};
13use rand::rngs::OsRng;
14use subtle::ConstantTimeEq;
15use x25519_dalek::{EphemeralSecret, PublicKey, StaticSecret};
16use zeroize::Zeroize;
17
18use crate::{
19    error::{DecryptError, EncryptError},
20    util::{parse_bech32, read::base64_arg},
21};
22
23// Use lower-case HRP to avoid https://github.com/rust-bitcoin/rust-bech32/issues/40
24const SECRET_KEY_PREFIX: &str = "age-secret-key-";
25const PUBLIC_KEY_PREFIX: &str = "age";
26
27pub(super) const X25519_RECIPIENT_TAG: &str = "X25519";
28const X25519_RECIPIENT_KEY_LABEL: &[u8] = b"age-encryption.org/v1/X25519";
29
30pub(super) const EPK_LEN_BYTES: usize = 32;
31pub(super) const ENCRYPTED_FILE_KEY_BYTES: usize = FILE_KEY_BYTES + 16;
32
33/// The standard age identity type, which can decrypt files encrypted to the corresponding
34/// [`Recipient`].
35#[derive(Clone)]
36pub struct Identity(StaticSecret);
37
38impl std::str::FromStr for Identity {
39    type Err = &'static str;
40
41    /// Parses an X25519 identity from a string.
42    fn from_str(s: &str) -> Result<Self, Self::Err> {
43        parse_bech32(s)
44            .ok_or("invalid Bech32 encoding")
45            .and_then(|(hrp, bytes)| {
46                if hrp == SECRET_KEY_PREFIX {
47                    TryInto::<[u8; 32]>::try_into(&bytes[..])
48                        .map_err(|_| "incorrect identity length")
49                        .map(StaticSecret::from)
50                        .map(Identity)
51                } else {
52                    Err("incorrect HRP")
53                }
54            })
55    }
56}
57
58impl Identity {
59    /// Generates a new secret key.
60    pub fn generate() -> Self {
61        let rng = OsRng;
62        Identity(StaticSecret::random_from_rng(rng))
63    }
64
65    /// Serializes this secret key as a string.
66    pub fn to_string(&self) -> SecretString {
67        let mut sk_bytes = self.0.to_bytes();
68        let sk_base32 = sk_bytes.to_base32();
69        let mut encoded =
70            bech32::encode(SECRET_KEY_PREFIX, sk_base32, Variant::Bech32).expect("HRP is valid");
71        let ret = SecretString::from(encoded.to_uppercase());
72
73        // Clear intermediates
74        sk_bytes.zeroize();
75        // TODO: bech32::u5 doesn't implement Zeroize
76        // sk_base32.zeroize();
77        encoded.zeroize();
78
79        ret
80    }
81
82    /// Returns the recipient key for this secret key.
83    pub fn to_public(&self) -> Recipient {
84        Recipient((&self.0).into())
85    }
86}
87
88impl crate::Identity for Identity {
89    fn unwrap_stanza(&self, stanza: &Stanza) -> Option<Result<FileKey, DecryptError>> {
90        if stanza.tag != X25519_RECIPIENT_TAG {
91            return None;
92        }
93
94        // Enforce valid and canonical stanza format.
95        // https://c2sp.org/age#x25519-recipient-stanza
96        let ephemeral_share = match &stanza.args[..] {
97            [arg] => match base64_arg::<_, EPK_LEN_BYTES, 33>(arg) {
98                Some(ephemeral_share) => ephemeral_share,
99                None => return Some(Err(DecryptError::InvalidHeader)),
100            },
101            _ => return Some(Err(DecryptError::InvalidHeader)),
102        };
103        if stanza.body.len() != ENCRYPTED_FILE_KEY_BYTES {
104            return Some(Err(DecryptError::InvalidHeader));
105        }
106
107        let epk: PublicKey = ephemeral_share.into();
108        let encrypted_file_key: [u8; ENCRYPTED_FILE_KEY_BYTES] = stanza.body[..]
109            .try_into()
110            .expect("Length should have been checked above");
111
112        let pk: PublicKey = (&self.0).into();
113        let shared_secret = self.0.diffie_hellman(&epk);
114        // Replace with `SharedSecret::was_contributory` once x25519-dalek supports newer
115        // zeroize (https://github.com/dalek-cryptography/x25519-dalek/issues/74#issuecomment-1159481280).
116        if shared_secret
117            .as_bytes()
118            .iter()
119            .fold(0, |acc, b| acc | b)
120            .ct_eq(&0)
121            .into()
122        {
123            return Some(Err(DecryptError::InvalidHeader));
124        }
125
126        let mut salt = [0; 64];
127        salt[..32].copy_from_slice(epk.as_bytes());
128        salt[32..].copy_from_slice(pk.as_bytes());
129
130        let enc_key = hkdf(&salt, X25519_RECIPIENT_KEY_LABEL, shared_secret.as_bytes());
131
132        // A failure to decrypt is non-fatal (we try to decrypt the recipient
133        // stanza with other X25519 keys), because we cannot tell which key
134        // matches a particular stanza.
135        aead_decrypt(&enc_key, FILE_KEY_BYTES, &encrypted_file_key)
136            .ok()
137            .map(|mut pt| {
138                // It's ours!
139                Ok(FileKey::init_with_mut(|file_key| {
140                    file_key.copy_from_slice(&pt);
141                    pt.zeroize();
142                }))
143            })
144    }
145}
146
147/// The standard age recipient type. Files encrypted to this recipient can be decrypted
148/// with the corresponding [`Identity`].
149///
150/// This recipient type is anonymous, in the sense that an attacker can't tell from the
151/// age-encrypted file alone if it is encrypted to a certain recipient.
152#[derive(Clone, PartialEq, Eq, Hash)]
153pub struct Recipient(PublicKey);
154
155impl std::str::FromStr for Recipient {
156    type Err = &'static str;
157
158    /// Parses a recipient key from a string.
159    fn from_str(s: &str) -> Result<Self, Self::Err> {
160        parse_bech32(s)
161            .ok_or("invalid Bech32 encoding")
162            .and_then(|(hrp, bytes)| {
163                if hrp == PUBLIC_KEY_PREFIX {
164                    TryInto::<[u8; 32]>::try_into(&bytes[..])
165                        .map_err(|_| "incorrect pubkey length")
166                        .map(PublicKey::from)
167                        .map(Recipient)
168                } else {
169                    Err("incorrect HRP")
170                }
171            })
172    }
173}
174
175impl fmt::Display for Recipient {
176    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
177        write!(
178            f,
179            "{}",
180            bech32::encode(
181                PUBLIC_KEY_PREFIX,
182                self.0.as_bytes().to_base32(),
183                Variant::Bech32
184            )
185            .expect("HRP is valid")
186        )
187    }
188}
189
190impl fmt::Debug for Recipient {
191    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
192        write!(f, "{}", self)
193    }
194}
195
196impl crate::Recipient for Recipient {
197    fn wrap_file_key(
198        &self,
199        file_key: &FileKey,
200    ) -> Result<(Vec<Stanza>, HashSet<String>), EncryptError> {
201        let rng = OsRng;
202        let esk = EphemeralSecret::random_from_rng(rng);
203        let epk: PublicKey = (&esk).into();
204        let shared_secret = esk.diffie_hellman(&self.0);
205
206        // It is vanishingly unlikely that we generate the all-zero esk, so if we do then
207        // it is likely that the RNG is bad, and we should fail loudly.
208        // Replace with `SharedSecret::was_contributory` once x25519-dalek supports
209        // newer zeroize (https://github.com/dalek-cryptography/x25519-dalek/issues/74#issuecomment-1159481280).
210        if bool::from(
211            shared_secret
212                .as_bytes()
213                .iter()
214                .fold(0, |acc, b| acc | b)
215                .ct_eq(&0),
216        ) {
217            panic!("Generated the all-zero esk; OS RNG is likely failing!");
218        }
219
220        let mut salt = [0; 64];
221        salt[..32].copy_from_slice(epk.as_bytes());
222        salt[32..].copy_from_slice(self.0.as_bytes());
223
224        let enc_key = hkdf(&salt, X25519_RECIPIENT_KEY_LABEL, shared_secret.as_bytes());
225        let encrypted_file_key = aead_encrypt(&enc_key, file_key.expose_secret());
226
227        let encoded_epk = BASE64_STANDARD_NO_PAD.encode(epk.as_bytes());
228
229        Ok((
230            vec![Stanza {
231                tag: X25519_RECIPIENT_TAG.to_owned(),
232                args: vec![encoded_epk],
233                body: encrypted_file_key,
234            }],
235            HashSet::new(),
236        ))
237    }
238}
239
240#[cfg(test)]
241pub(crate) mod tests {
242    use age_core::{format::FileKey, secrecy::ExposeSecret};
243    use proptest::prelude::*;
244    use x25519_dalek::{PublicKey, StaticSecret};
245
246    use super::{Identity, Recipient};
247    use crate::{Identity as _, Recipient as _};
248
249    pub(crate) const TEST_SK: &str =
250        "AGE-SECRET-KEY-1GQ9778VQXMMJVE8SK7J6VT8UJ4HDQAJUVSFCWCM02D8GEWQ72PVQ2Y5J33";
251    pub(crate) const TEST_PK: &str =
252        "age1t7rxyev2z3rw82stdlrrepyc39nvn86l5078zqkf5uasdy86jp6svpy7pa";
253
254    #[test]
255    fn pubkey_encoding() {
256        let pk: Recipient = TEST_PK.parse().unwrap();
257        assert_eq!(pk.to_string(), TEST_PK);
258    }
259
260    #[test]
261    fn pubkey_from_secret_key() {
262        let key = TEST_SK.parse::<Identity>().unwrap();
263        assert_eq!(key.to_public().to_string(), TEST_PK);
264    }
265
266    proptest! {
267        #[test]
268        fn wrap_and_unwrap(sk_bytes in proptest::collection::vec(any::<u8>(), ..=32)) {
269            let file_key = FileKey::new(Box::new([7; 16]));
270            let sk = {
271                let mut tmp = [0; 32];
272                tmp[..sk_bytes.len()].copy_from_slice(&sk_bytes);
273                StaticSecret::from(tmp)
274            };
275
276            let res = Recipient(PublicKey::from(&sk))
277                .wrap_file_key(&file_key);
278            prop_assert!(res.is_ok());
279            let (stanzas, labels) = res.unwrap();
280            prop_assert!(labels.is_empty());
281
282            let res = Identity(sk).unwrap_stanzas(&stanzas);
283            prop_assert!(res.is_some());
284            let res = res.unwrap();
285            prop_assert!(res.is_ok());
286            let res = res.unwrap();
287
288            prop_assert_eq!(res.expose_secret(), file_key.expose_secret());
289        }
290    }
291}