web_push/
http_ece.rs

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