web_push/
http_ece.rs

1//! Payload encryption algorithm
2
3use ece::encrypt;
4
5use crate::error::WebPushError;
6use crate::message::WebPushPayload;
7use crate::vapid::VapidSignature;
8
9/// Content encoding profiles.
10#[derive(Debug, PartialEq, Copy, Clone, Default)]
11pub enum ContentEncoding {
12    //Make sure this enum remains exhaustive as that allows for easier migrations to new versions.
13    #[default]
14    Aes128Gcm,
15    /// Note: this is an older version of ECE, and should not be used unless you know for sure it is required. In all other cases, use aes128gcm.
16    AesGcm,
17}
18
19impl ContentEncoding {
20    /// Gets the associated string for this content encoding, as would be used in the content-encoding header.
21    pub fn to_str(&self) -> &'static str {
22        match &self {
23            ContentEncoding::Aes128Gcm => "aes128gcm",
24            ContentEncoding::AesGcm => "aesgcm",
25        }
26    }
27}
28
29/// Struct for handling payload encryption.
30pub struct HttpEce<'a> {
31    peer_public_key: &'a [u8],
32    peer_secret: &'a [u8],
33    encoding: ContentEncoding,
34    vapid_signature: Option<VapidSignature>,
35}
36
37impl<'a> HttpEce<'a> {
38    /// Create a new encryptor.
39    ///
40    /// `peer_public_key` is the `p256dh` and `peer_secret` the `auth` from
41    /// browser subscription info.
42    pub fn new(
43        encoding: ContentEncoding,
44        peer_public_key: &'a [u8],
45        peer_secret: &'a [u8],
46        vapid_signature: Option<VapidSignature>,
47    ) -> HttpEce<'a> {
48        HttpEce {
49            peer_public_key,
50            peer_secret,
51            encoding,
52            vapid_signature,
53        }
54    }
55
56    /// Encrypts a payload. The maximum length for the payload is 3800
57    /// characters, which is the largest that works with Google's and Mozilla's
58    /// push servers.
59    pub fn encrypt(&self, content: &'a [u8]) -> Result<WebPushPayload, WebPushError> {
60        if content.len() > 3052 {
61            return Err(WebPushError::PayloadTooLarge);
62        }
63
64        //Add more encoding standards to this match as they are created.
65        match self.encoding {
66            ContentEncoding::Aes128Gcm => {
67                let result = encrypt(self.peer_public_key, self.peer_secret, content);
68
69                let mut headers = Vec::new();
70
71                self.add_vapid_headers(&mut headers);
72
73                match result {
74                    Ok(data) => Ok(WebPushPayload {
75                        content: data,
76                        crypto_headers: headers,
77                        content_encoding: self.encoding,
78                    }),
79                    _ => Err(WebPushError::InvalidCryptoKeys),
80                }
81            }
82            ContentEncoding::AesGcm => {
83                let result = self.aesgcm_encrypt(content);
84
85                let data = result.map_err(|_| WebPushError::InvalidCryptoKeys)?;
86
87                // Get headers exclusive to the aesgcm scheme (Crypto-Key ect.)
88                let mut headers = data.headers(self.vapid_signature.as_ref().map(|v| v.auth_k.as_slice()));
89
90                self.add_vapid_headers(&mut headers);
91
92                // ECE library base64 encodes content in aesgcm, but not aes128gcm, so decode base64 here to match the 128 API
93                let data = base64::decode_config(data.body(), base64::URL_SAFE_NO_PAD)
94                    .expect("ECE library should always base64 encode");
95
96                Ok(WebPushPayload {
97                    content: data,
98                    crypto_headers: headers,
99                    content_encoding: self.encoding,
100                })
101            }
102        }
103    }
104
105    /// Adds VAPID authorisation header to headers, if VAPID is being used.
106    fn add_vapid_headers(&self, headers: &mut Vec<(&str, String)>) {
107        //VAPID uses a special Authorisation header, which contains a ecdhsa key and a jwt.
108        if let Some(signature) = &self.vapid_signature {
109            headers.push((
110                "Authorization",
111                format!(
112                    "vapid t={}, k={}",
113                    signature.auth_t,
114                    base64::encode_config(&signature.auth_k, base64::URL_SAFE_NO_PAD)
115                ),
116            ));
117        }
118    }
119
120    /// Encrypts the content using the aesgcm encoding.
121    ///
122    /// This is extracted into a function for testing.
123    fn aesgcm_encrypt(&self, content: &[u8]) -> ece::Result<ece::legacy::AesGcmEncryptedBlock> {
124        ece::legacy::encrypt_aesgcm(self.peer_public_key, self.peer_secret, content)
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use base64::{self, URL_SAFE};
131    use regex::Regex;
132
133    use crate::error::WebPushError;
134    use crate::http_ece::{ContentEncoding, HttpEce};
135    use crate::VapidSignature;
136    use crate::WebPushPayload;
137
138    #[test]
139    fn test_payload_too_big() {
140        let p256dh = base64::decode_config(
141            "BLMaF9ffKBiWQLCKvTHb6LO8Nb6dcUh6TItC455vu2kElga6PQvUmaFyCdykxY2nOSSL3yKgfbmFLRTUaGv4yV8",
142            URL_SAFE,
143        )
144        .unwrap();
145        let auth = base64::decode_config("xS03Fj5ErfTNH_l9WHE9Ig", URL_SAFE).unwrap();
146        let http_ece = HttpEce::new(ContentEncoding::Aes128Gcm, &p256dh, &auth, None);
147        //This content is one above limit.
148        let content = [0u8; 3801];
149
150        assert_eq!(Err(WebPushError::PayloadTooLarge), http_ece.encrypt(&content));
151    }
152
153    /// Tests that the content encryption is properly reversible while using aes128gcm.
154    #[test]
155    fn test_payload_encrypts_128() {
156        let (key, auth) = ece::generate_keypair_and_auth_secret().unwrap();
157        let p_key = key.raw_components().unwrap();
158        let p_key = p_key.public_key();
159
160        let http_ece = HttpEce::new(ContentEncoding::Aes128Gcm, p_key, &auth, None);
161        let plaintext = "Hello world!";
162        let ciphertext = http_ece.encrypt(plaintext.as_bytes()).unwrap();
163
164        assert_ne!(plaintext.as_bytes(), ciphertext.content);
165
166        assert_eq!(
167            String::from_utf8(ece::decrypt(&key.raw_components().unwrap(), &auth, &ciphertext.content).unwrap())
168                .unwrap(),
169            plaintext
170        )
171    }
172
173    /// Tests that the content encryption is properly reversible while using aesgcm.
174    #[test]
175    fn test_payload_encrypts() {
176        let (key, auth) = ece::generate_keypair_and_auth_secret().unwrap();
177        let p_key = key.raw_components().unwrap();
178        let p_key = p_key.public_key();
179
180        let http_ece = HttpEce::new(ContentEncoding::AesGcm, p_key, &auth, None);
181        let plaintext = "Hello world!";
182        let ciphertext = http_ece.aesgcm_encrypt(plaintext.as_bytes()).unwrap();
183
184        assert_ne!(plaintext, ciphertext.body());
185
186        assert_eq!(
187            String::from_utf8(ece::legacy::decrypt_aesgcm(&key.raw_components().unwrap(), &auth, &ciphertext).unwrap())
188                .unwrap(),
189            plaintext
190        )
191    }
192
193    fn setup_payload(vapid_signature: Option<VapidSignature>, encoding: ContentEncoding) -> WebPushPayload {
194        let p256dh = base64::decode_config(
195            "BLMbF9ffKBiWQLCKvTHb6LO8Nb6dcUh6TItC455vu2kElga6PQvUmaFyCdykxY2nOSSL3yKgfbmFLRTUaGv4yV8",
196            URL_SAFE,
197        )
198        .unwrap();
199        let auth = base64::decode_config("xS03Fi5ErfTNH_l9WHE9Ig", URL_SAFE).unwrap();
200
201        let http_ece = HttpEce::new(encoding, &p256dh, &auth, vapid_signature);
202        let content = "Hello, world!".as_bytes();
203
204        http_ece.encrypt(content).unwrap()
205    }
206
207    #[test]
208    fn test_aes128gcm_headers_no_vapid() {
209        let wp_payload = setup_payload(None, ContentEncoding::Aes128Gcm);
210        assert_eq!(wp_payload.crypto_headers.len(), 0);
211    }
212
213    #[test]
214    fn test_aesgcm_headers_no_vapid() {
215        let wp_payload = setup_payload(None, ContentEncoding::AesGcm);
216        assert_eq!(wp_payload.crypto_headers.len(), 2);
217    }
218
219    #[test]
220    fn test_aes128gcm_headers_vapid() {
221        let auth_re = Regex::new(r"vapid t=(?P<sig_t>[^,]*), k=(?P<sig_k>[^,]*)").unwrap();
222        let vapid_signature = VapidSignature {
223            auth_t: String::from("foo"),
224            auth_k: String::from("bar").into_bytes(),
225        };
226        let wp_payload = setup_payload(Some(vapid_signature), ContentEncoding::Aes128Gcm);
227        assert_eq!(wp_payload.crypto_headers.len(), 1);
228        let auth = wp_payload.crypto_headers[0].clone();
229        assert_eq!(auth.0, "Authorization");
230        assert!(auth_re.captures(&auth.1).is_some());
231    }
232
233    #[test]
234    fn test_aesgcm_headers_vapid() {
235        let auth_re = Regex::new(r"vapid t=(?P<sig_t>[^,]*), k=(?P<sig_k>[^,]*)").unwrap();
236        let vapid_signature = VapidSignature {
237            auth_t: String::from("foo"),
238            auth_k: String::from("bar").into_bytes(),
239        };
240        let wp_payload = setup_payload(Some(vapid_signature), ContentEncoding::AesGcm);
241        // Should have Authorization, Crypto-key, and Encryption
242        assert_eq!(wp_payload.crypto_headers.len(), 3);
243        let auth = wp_payload.crypto_headers[2].clone();
244        assert_eq!(auth.0, "Authorization");
245        assert!(auth_re.captures(&auth.1).is_some());
246    }
247}