push/internal/
crypto.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5//! Module providing all the cryptography needed by the push component
6//!
7//! Mainly exports a trait [`Cryptography`] and a concrete type that implements that trait
8//! [`Crypto`]
9//!
10//! The push component encrypts its push notifications. When a subscription is created,
11//! [`Cryptography::generate_key`] is called to generate a public/private key pair.
12//!
13//! The public key is then given to the subscriber (for example, Firefox Accounts) and the private key
14//! is persisted in the client. Subscribers are required to encrypt their payloads using the public key and
15//! when delivered to the client, the client would load the private key from storage and decrypt the payload.
16//!
17
18use std::borrow::Cow;
19use std::collections::HashMap;
20use std::fmt::Display;
21use std::str::FromStr;
22
23use crate::{error, PushError};
24use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
25use rc_crypto::ece::{self, EcKeyComponents, LocalKeyPair};
26use rc_crypto::ece_crypto::RcCryptoLocalKeyPair;
27use rc_crypto::rand;
28use serde::{Deserialize, Serialize};
29
30pub const SER_AUTH_LENGTH: usize = 16;
31pub type Decrypted = Vec<u8>;
32
33#[derive(Serialize, Deserialize, Clone)]
34pub(crate) enum VersionnedKey<'a> {
35    V1(Cow<'a, KeyV1>),
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
39enum CryptoEncoding {
40    Aesgcm,
41    Aes128gcm,
42}
43
44impl FromStr for CryptoEncoding {
45    type Err = PushError;
46
47    fn from_str(s: &str) -> Result<Self, Self::Err> {
48        Ok(match s.to_lowercase().as_str() {
49            "aesgcm" => Self::Aesgcm,
50            "aes128gcm" => Self::Aes128gcm,
51            _ => {
52                return Err(PushError::CryptoError(format!(
53                    "Invalid crypto encoding {}",
54                    s
55                )))
56            }
57        })
58    }
59}
60
61impl Display for CryptoEncoding {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        write!(
64            f,
65            "{}",
66            match self {
67                Self::Aesgcm => "aesgcm",
68                Self::Aes128gcm => "aes128gcm",
69            }
70        )
71    }
72}
73
74#[derive(Clone, PartialEq, Serialize, Deserialize)]
75pub struct KeyV1 {
76    pub(crate) p256key: EcKeyComponents,
77    pub(crate) auth: Vec<u8>,
78}
79pub type Key = KeyV1;
80
81impl std::fmt::Debug for KeyV1 {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.debug_struct("KeyV1").finish()
84    }
85}
86
87impl Key {
88    // We define this method so the type-checker prevents us from
89    // trying to serialize `Key` directly since `bincode::serialize`
90    // would compile because both types derive `Serialize`.
91    pub(crate) fn serialize(&self) -> error::Result<Vec<u8>> {
92        Ok(bincode::serialize(&VersionnedKey::V1(Cow::Borrowed(self)))?)
93    }
94
95    pub(crate) fn deserialize(bytes: &[u8]) -> error::Result<Self> {
96        let versionned = bincode::deserialize(bytes)?;
97        match versionned {
98            VersionnedKey::V1(prv_key) => Ok(prv_key.into_owned()),
99        }
100    }
101
102    pub fn key_pair(&self) -> &EcKeyComponents {
103        &self.p256key
104    }
105
106    pub fn auth_secret(&self) -> &[u8] {
107        &self.auth
108    }
109
110    pub fn private_key(&self) -> &[u8] {
111        self.p256key.private_key()
112    }
113
114    pub fn public_key(&self) -> &[u8] {
115        self.p256key.public_key()
116    }
117}
118
119#[cfg_attr(test, mockall::automock)]
120pub trait Cryptography: Default {
121    /// generate a new local EC p256 key
122    fn generate_key() -> error::Result<Key>;
123
124    /// General decrypt function. Calls to decrypt_aesgcm or decrypt_aes128gcm as needed.
125    #[allow(clippy::needless_lifetimes)]
126    // Clippy complains here although the lifetime is needed, seems like a bug with automock
127    fn decrypt<'a>(key: &Key, push_payload: PushPayload<'a>) -> error::Result<Decrypted>;
128
129    /// Decrypt the obsolete "aesgcm" format (which is still used by a number of providers)
130    fn decrypt_aesgcm(
131        key: &Key,
132        content: &[u8],
133        salt: Option<Vec<u8>>,
134        crypto_key: Option<Vec<u8>>,
135    ) -> error::Result<Decrypted>;
136
137    /// Decrypt the RFC 8188 format.
138    fn decrypt_aes128gcm(key: &Key, content: &[u8]) -> error::Result<Decrypted>;
139}
140
141#[derive(Default)]
142pub struct Crypto;
143
144pub fn get_random_bytes(size: usize) -> error::Result<Vec<u8>> {
145    let mut bytes = vec![0u8; size];
146    rand::fill(&mut bytes).map_err(|e| {
147        error::PushError::CryptoError(format!("Could not generate random bytes: {:?}", e))
148    })?;
149    Ok(bytes)
150}
151
152/// Extract the sub-value from the header.
153/// Sub values have the form of `label=value`. Due to a bug in some push providers, treat ',' and ';' as
154/// equivalent.
155fn extract_value(val: &str, target: &str) -> Option<Vec<u8>> {
156    if !val.contains(&format!("{}=", target)) {
157        error::debug!("No sub-value found for {}", target);
158        return None;
159    }
160    let items = val.split([',', ';']);
161    for item in items {
162        let mut kv = item.split('=');
163        if kv.next() == Some(target) {
164            if let Some(val) = kv.next() {
165                return match URL_SAFE_NO_PAD.decode(val) {
166                    Ok(v) => Some(v),
167                    Err(e) => {
168                        error_support::report_error!(
169                            "push-base64-decode",
170                            "base64 failed for target:{}; {:?}",
171                            target,
172                            e
173                        );
174                        None
175                    }
176                };
177            }
178        }
179    }
180    None
181}
182
183impl Cryptography for Crypto {
184    fn generate_key() -> error::Result<Key> {
185        rc_crypto::ensure_initialized();
186
187        let key = RcCryptoLocalKeyPair::generate_random()?;
188        let components = key.raw_components()?;
189        let auth = get_random_bytes(SER_AUTH_LENGTH)?;
190        Ok(Key {
191            p256key: components,
192            auth,
193        })
194    }
195
196    fn decrypt(key: &Key, push_payload: PushPayload<'_>) -> error::Result<Decrypted> {
197        rc_crypto::ensure_initialized();
198        // convert the private key into something useful.
199        let d_salt = extract_value(push_payload.salt, "salt");
200        let d_dh = extract_value(push_payload.dh, "dh");
201        let d_body = URL_SAFE_NO_PAD.decode(push_payload.body)?;
202
203        match CryptoEncoding::from_str(push_payload.encoding)? {
204            CryptoEncoding::Aesgcm => Self::decrypt_aesgcm(key, &d_body, d_salt, d_dh),
205            CryptoEncoding::Aes128gcm => Self::decrypt_aes128gcm(key, &d_body),
206        }
207    }
208
209    fn decrypt_aesgcm(
210        key: &Key,
211        content: &[u8],
212        salt: Option<Vec<u8>>,
213        crypto_key: Option<Vec<u8>>,
214    ) -> error::Result<Decrypted> {
215        let dh = crypto_key
216            .ok_or_else(|| error::PushError::CryptoError("Missing public key".to_string()))?;
217        let salt = salt.ok_or_else(|| error::PushError::CryptoError("Missing salt".to_string()))?;
218        let block = ece::legacy::AesGcmEncryptedBlock::new(&dh, &salt, 4096, content.to_vec())?;
219        Ok(ece::legacy::decrypt_aesgcm(
220            key.key_pair(),
221            key.auth_secret(),
222            &block,
223        )?)
224    }
225
226    fn decrypt_aes128gcm(key: &Key, content: &[u8]) -> error::Result<Vec<u8>> {
227        Ok(ece::decrypt(key.key_pair(), key.auth_secret(), content)?)
228    }
229}
230
231#[derive(Debug, Deserialize)]
232pub struct PushPayload<'a> {
233    pub(crate) channel_id: &'a str,
234    pub(crate) body: &'a str,
235    pub(crate) encoding: &'a str,
236    pub(crate) salt: &'a str,
237    pub(crate) dh: &'a str,
238}
239
240impl<'a> TryFrom<&'a HashMap<String, String>> for PushPayload<'a> {
241    type Error = PushError;
242
243    fn try_from(value: &'a HashMap<String, String>) -> Result<Self, Self::Error> {
244        let channel_id = value
245            .get("chid")
246            .ok_or_else(|| PushError::CryptoError("Invalid Push payload".to_string()))?;
247        let body = value
248            .get("body")
249            .ok_or_else(|| PushError::CryptoError("Invalid Push payload".to_string()))?;
250        let encoding = value.get("con").map(|s| s.as_str()).unwrap_or("aes128gcm");
251        let salt = value.get("enc").map(|s| s.as_str()).unwrap_or("");
252        let dh = value.get("cryptokey").map(|s| s.as_str()).unwrap_or("");
253        Ok(Self {
254            channel_id,
255            body,
256            encoding,
257            salt,
258            dh,
259        })
260    }
261}
262
263#[cfg(test)]
264mod crypto_tests {
265    use super::*;
266    use nss::ensure_initialized;
267
268    // generate unit test key
269    fn test_key(priv_key: &str, pub_key: &str, auth: &str) -> Key {
270        let components = EcKeyComponents::new(
271            URL_SAFE_NO_PAD.decode(priv_key).unwrap(),
272            URL_SAFE_NO_PAD.decode(pub_key).unwrap(),
273        );
274        let auth = URL_SAFE_NO_PAD.decode(auth).unwrap();
275        Key {
276            p256key: components,
277            auth,
278        }
279    }
280
281    const PLAINTEXT:&str = "Amidst the mists and coldest frosts I thrust my fists against the\nposts and still demand to see the ghosts.\n\n";
282
283    fn decrypter(ciphertext: &str, encoding: &str, salt: &str, dh: &str) -> error::Result<Vec<u8>> {
284        let priv_key_d = "qJkxxWGVVxy7BKvraNY3hg8Gs-Y8qi0lRaXWJ3R3aJ8";
285        // The auth token
286        let auth_raw = "LsuUOBKVQRY6-l7_Ajo-Ag";
287        // This would be the public key sent to the subscription service.
288        let pub_key_raw = "BBcJdfs1GtMyymFTtty6lIGWRFXrEtJP40Df0gOvRDR4D8CKVgqE6vlYR7tCYksIRdKD1MxDPhQVmKLnzuife50";
289
290        let key = test_key(priv_key_d, pub_key_raw, auth_raw);
291        Crypto::decrypt(
292            &key,
293            PushPayload {
294                channel_id: "channel_id",
295                body: ciphertext,
296                encoding,
297                salt,
298                dh,
299            },
300        )
301    }
302
303    #[test]
304    fn test_decrypt_aesgcm() {
305        ensure_initialized();
306
307        // The following comes from the delivered message body
308        let ciphertext = "BNKu5uTFhjyS-06eECU9-6O61int3Rr7ARbm-xPhFuyDO5sfxVs-HywGaVonvzkarvfvXE9IRT_YNA81Og2uSqDasdMuw\
309                          qm1zd0O3f7049IkQep3RJ2pEZTy5DqvI7kwMLDLzea9nroq3EMH5hYhvQtQgtKXeWieEL_3yVDQVg";
310        // and now from the header values
311        let dh = "keyid=foo;dh=BMOebOMWSRisAhWpRK9ZPszJC8BL9MiWvLZBoBU6pG6Kh6vUFSW4BHFMh0b83xCg3_7IgfQZXwmVuyu27vwiv5c,otherval=abcde";
312        let salt = "salt=tSf2qu43C9BD0zkvRW5eUg";
313
314        // and this is what it should be.
315
316        let decrypted = decrypter(ciphertext, "aesgcm", salt, dh).unwrap();
317
318        assert_eq!(String::from_utf8(decrypted).unwrap(), PLAINTEXT.to_string());
319    }
320
321    #[test]
322    fn test_fail_decrypt_aesgcm() {
323        ensure_initialized();
324
325        let ciphertext = "BNKu5uTFhjyS-06eECU9-6O61int3Rr7ARbm-xPhFuyDO5sfxVs-HywGaVonvzkarvfvXE9IRT_\
326                          YNA81Og2uSqDasdMuwqm1zd0O3f7049IkQep3RJ2pEZTy5DqvI7kwMLDLzea9nroq3EMH5hYhvQtQgtKXeWieEL_3yVDQVg";
327        let dh = "dh=BMOebOMWSRisAhWpRK9ZPszJC8BL9MiWvLZBoBU6pG6Kh6vUFSW4BHFMh0b83xCg3_7IgfQZXwmVuyu27vwiv5c";
328        let salt = "salt=SomeInvalidSaltValue";
329
330        decrypter(ciphertext, "aesgcm", salt, dh).expect_err("Failed to abort, bad salt");
331    }
332
333    #[test]
334    fn test_decrypt_aes128gcm() {
335        ensure_initialized();
336
337        let ciphertext = "Ek7iQgliMqS9kjFoiVOqRgAAEABBBFirfBtF6XTeHVPABFDveb1iu7uO1XVA_MYJeAo-\
338             4ih8WYUsXSTIYmkKMv5_UB3tZuQI7BQ2EVpYYQfvOCrWZVMRL8fJCuB5wVXcoRoTaFJw\
339             TlJ5hnw6IMSiaMqGVlc8drX7Hzy-ugzzAKRhGPV2x-gdsp58DZh9Ww5vHpHyT1xwVkXz\
340             x3KTyeBZu4gl_zR0Q00li17g0xGsE6Dg3xlkKEmaalgyUyObl6_a8RA6Ko1Rc6RhAy2jdyY1LQbBUnA";
341
342        let decrypted = decrypter(ciphertext, "aes128gcm", "", "").unwrap();
343        assert_eq!(String::from_utf8(decrypted).unwrap(), PLAINTEXT.to_string());
344    }
345}