web_push/vapid/
builder.rs

1use std::{collections::BTreeMap, io::Read};
2
3use ct_codecs::Base64UrlSafeNoPadding;
4use http::uri::Uri;
5use jwt_simple::prelude::*;
6use serde_json::Value;
7
8use crate::{
9    error::WebPushError,
10    message::SubscriptionInfo,
11    vapid::{signer::Claims, VapidKey, VapidSignature, VapidSigner},
12};
13
14/// A VAPID signature builder for generating an optional signature to the
15/// request. This encryption is required for payloads in all current and future browsers.
16///
17/// To communicate with the site, one needs to generate a private key to keep in
18/// the server and derive a public key from the generated private key for the
19/// client.
20///
21/// Private key generation:
22///
23/// ```bash,ignore
24/// openssl ecparam -name prime256v1 -genkey -noout -out private.pem
25/// ```
26///
27/// To derive a public key out of generated private key:
28///
29/// ```bash,ignore
30/// openssl ec -in private.pem -pubout -out vapid_public.pem
31/// ```
32///
33/// To get the byte form of the public key for the JavaScript client:
34///
35/// ```bash,ignore
36/// openssl ec -in private.pem -text -noout -conv_form uncompressed
37/// ```
38///
39/// ... or a base64-encoded string, which the client should convert into
40/// byte form before using:
41///
42/// ```bash,ignore
43/// openssl ec -in private.pem -pubout -outform DER|tail -c 65|base64|tr '/+' '_-'|tr -d '\n'
44/// ```
45///
46/// The above commands can be done in code using [`PartialVapidSignatureBuilder::get_public_key`], then base64 URL safe
47/// encoding as well.
48///
49/// To create a VAPID signature:
50///
51/// ```no_run
52/// # extern crate web_push;
53/// # use web_push::*;
54/// # use std::fs::File;
55/// # fn main () {
56/// //You would get this as a `pushSubscription` object from the client. They need your public key to get that object.
57/// let subscription_info = SubscriptionInfo {
58///     keys: SubscriptionKeys {
59///         p256dh: String::from("something"),
60///         auth: String::from("secret"),
61///     },
62///     endpoint: String::from("https://mozilla.rules/something"),
63/// };
64///
65/// let file = File::open("private.pem").unwrap();
66///
67/// let mut sig_builder = VapidSignatureBuilder::from_pem(file, &subscription_info).unwrap();
68///
69/// //These fields are optional, and likely unneeded for most uses.
70/// sig_builder.add_claim("sub", "mailto:test@example.com");
71/// sig_builder.add_claim("foo", "bar");
72/// sig_builder.add_claim("omg", 123);
73///
74/// let signature = sig_builder.build().unwrap();
75/// # }
76/// ```
77pub struct VapidSignatureBuilder<'a> {
78    claims: Claims,
79    key: VapidKey,
80    subscription_info: &'a SubscriptionInfo,
81}
82
83impl<'a> VapidSignatureBuilder<'a> {
84    /// Creates a new builder from a PEM formatted private key.
85    ///
86    /// # Details
87    ///
88    /// The input can be either a pkcs8 formatted PEM, denoted by a -----BEGIN PRIVATE KEY------
89    /// header, or a SEC1 formatted PEM, denoted by a -----BEGIN EC PRIVATE KEY------ header.
90    pub fn from_pem<R: Read>(
91        pk_pem: R,
92        subscription_info: &'a SubscriptionInfo,
93    ) -> Result<VapidSignatureBuilder<'a>, WebPushError> {
94        let pr_key = Self::read_pem(pk_pem)?;
95
96        Ok(Self::from_ec(pr_key, subscription_info))
97    }
98
99    /// Creates a new builder from a PEM formatted private key. This function doesn't take a subscription,
100    /// allowing the reuse of one builder for multiple messages by cloning the resulting builder.
101    ///
102    /// # Details
103    ///
104    /// The input can be either a pkcs8 formatted PEM, denoted by a -----BEGIN PRIVATE KEY------
105    /// header, or a SEC1 formatted PEM, denoted by a -----BEGIN EC PRIVATE KEY------ header.
106    pub fn from_pem_no_sub<R: Read>(pk_pem: R) -> Result<PartialVapidSignatureBuilder, WebPushError> {
107        let pr_key = Self::read_pem(pk_pem)?;
108
109        Ok(PartialVapidSignatureBuilder {
110            key: VapidKey::new(pr_key),
111        })
112    }
113
114    /// Creates a new builder from a DER formatted private key.
115    pub fn from_der<R: Read>(
116        mut pk_der: R,
117        subscription_info: &'a SubscriptionInfo,
118    ) -> Result<VapidSignatureBuilder<'a>, WebPushError> {
119        let mut der_key: Vec<u8> = Vec::new();
120        pk_der.read_to_end(&mut der_key)?;
121
122        Ok(Self::from_ec(
123            ES256KeyPair::from_bytes(
124                &sec1_decode::parse_der(&der_key)
125                    .map_err(|_| WebPushError::InvalidCryptoKeys)?
126                    .key,
127            )
128            .map_err(|_| WebPushError::InvalidCryptoKeys)?,
129            subscription_info,
130        ))
131    }
132
133    /// Creates a new builder from a DER formatted private key. This function doesn't take a subscription,
134    /// allowing the reuse of one builder for multiple messages by cloning the resulting builder.
135    pub fn from_der_no_sub<R: Read>(mut pk_der: R) -> Result<PartialVapidSignatureBuilder, WebPushError> {
136        let mut der_key: Vec<u8> = Vec::new();
137        pk_der.read_to_end(&mut der_key)?;
138
139        Ok(PartialVapidSignatureBuilder {
140            key: VapidKey::new(
141                ES256KeyPair::from_bytes(
142                    &sec1_decode::parse_der(&der_key)
143                        .map_err(|_| WebPushError::InvalidCryptoKeys)?
144                        .key,
145                )
146                .map_err(|_| WebPushError::InvalidCryptoKeys)?,
147            ),
148        })
149    }
150
151    /// Creates a new builder from a raw base64-encoded private key. This isn't the base64 from a key
152    /// generated by openssl, but rather the literal bytes of the private key itself. This is the kind
153    /// of key given to you by most VAPID key generator sites, and also the kind used in the API of other
154    /// large web push libraries, such as PHP and Node.
155    ///
156    /// Base64 encoding must use URL-safe alphabet without padding.
157    ///
158    /// # Example
159    ///
160    /// ```
161    /// # use web_push::VapidSignatureBuilder;
162    /// // Use `from_base64` here if you have a sub
163    /// let builder = VapidSignatureBuilder::from_base64_no_sub("IQ9Ur0ykXoHS9gzfYX0aBjy9lvdrjx_PFUXmie9YRcY").unwrap();
164    /// ```
165    pub fn from_base64(
166        encoded: &str,
167        subscription_info: &'a SubscriptionInfo,
168    ) -> Result<VapidSignatureBuilder<'a>, WebPushError> {
169        let pr_key = ES256KeyPair::from_bytes(
170            &Base64UrlSafeNoPadding::decode_to_vec(encoded, None).map_err(|_| WebPushError::InvalidCryptoKeys)?,
171        )
172        .map_err(|_| WebPushError::InvalidCryptoKeys)?;
173
174        Ok(Self::from_ec(pr_key, subscription_info))
175    }
176
177    /// Creates a new builder from a raw base64-encoded private key. This function doesn't take a subscription,
178    /// allowing the reuse of one builder for multiple messages by cloning the resulting builder.
179    ///
180    /// Base64 encoding must use URL-safe alphabet without padding.
181    ///
182    pub fn from_base64_no_sub(encoded: &str) -> Result<PartialVapidSignatureBuilder, WebPushError> {
183        let pr_key = ES256KeyPair::from_bytes(
184            &Base64UrlSafeNoPadding::decode_to_vec(encoded, None).map_err(|_| WebPushError::InvalidCryptoKeys)?,
185        )
186        .map_err(|_| WebPushError::InvalidCryptoKeys)?;
187
188        Ok(PartialVapidSignatureBuilder {
189            key: VapidKey::new(pr_key),
190        })
191    }
192
193    /// Add a claim to the signature. Claims `aud` and `exp` are automatically
194    /// added to the signature. Add them manually to override the default
195    /// values.
196    ///
197    /// The function accepts any value that can be converted into a type JSON
198    /// supports.
199    pub fn add_claim<V>(&mut self, key: &'a str, val: V)
200    where
201        V: Into<Value>,
202    {
203        self.claims.custom.insert(key.to_string(), val.into());
204    }
205
206    /// Builds a signature to be used in [WebPushMessageBuilder](struct.WebPushMessageBuilder.html).
207    pub fn build(self) -> Result<VapidSignature, WebPushError> {
208        let endpoint: Uri = self.subscription_info.endpoint.parse()?;
209        let signature = VapidSigner::sign(self.key, &endpoint, self.claims)?;
210
211        Ok(signature)
212    }
213
214    fn from_ec(ec_key: ES256KeyPair, subscription_info: &'a SubscriptionInfo) -> VapidSignatureBuilder<'a> {
215        VapidSignatureBuilder {
216            claims: jwt_simple::prelude::Claims::with_custom_claims(BTreeMap::new(), Duration::from_hours(12)),
217            key: VapidKey::new(ec_key),
218            subscription_info,
219        }
220    }
221
222    /// Reads the pem file as either format sec1 or pkcs8, then returns the decoded private key.
223    pub(crate) fn read_pem<R: Read>(mut input: R) -> Result<ES256KeyPair, WebPushError> {
224        let mut buffer = String::new();
225        input.read_to_string(&mut buffer)?;
226
227        //Parse many PEM in the assumption of extra unneeded sections.
228        let parsed = pem::parse_many(&buffer).map_err(|_| WebPushError::InvalidCryptoKeys)?;
229
230        let found_pkcs8 = parsed.iter().any(|pem| pem.tag() == "PRIVATE KEY");
231        let found_sec1 = parsed.iter().any(|pem| pem.tag() == "EC PRIVATE KEY");
232
233        //Handle each kind of PEM file differently, as EC keys can be in SEC1 or PKCS8 format.
234        if found_sec1 {
235            let key = sec1_decode::parse_pem(buffer.as_bytes()).map_err(|_| WebPushError::InvalidCryptoKeys)?;
236            Ok(ES256KeyPair::from_bytes(&key.key).map_err(|_| WebPushError::InvalidCryptoKeys)?)
237        } else if found_pkcs8 {
238            Ok(ES256KeyPair::from_pem(&buffer).map_err(|_| WebPushError::InvalidCryptoKeys)?)
239        } else {
240            Err(WebPushError::MissingCryptoKeys)
241        }
242    }
243}
244
245/// A [`VapidSignatureBuilder`] without VAPID subscription info.
246///
247/// # Example
248///
249/// ```no_run
250/// use web_push::{VapidSignatureBuilder, SubscriptionInfo};
251///
252/// let builder = VapidSignatureBuilder::from_pem_no_sub("Some PEM".as_bytes()).unwrap();
253///
254/// //Clone builder for each use of the same private key
255/// {
256///     //Pretend this changes for each connection
257///     let subscription_info = SubscriptionInfo::new(
258///     "https://updates.push.services.mozilla.com/wpush/v1/...",
259///     "BLMbF9ffKBiWQLCKvTHb6LO8Nb6dcUh6TItC455vu2kElga6PQvUmaFyCdykxY2nOSSL3yKgfbmFLRTUaGv4yV8",
260///     "xS03Fi5ErfTNH_l9WHE9Ig"
261///     );
262///
263///     let builder = builder.clone();
264///     let sig = builder.add_sub_info(&subscription_info).build();
265///     //Sign message ect.
266/// }
267///
268/// ```
269#[derive(Clone)]
270pub struct PartialVapidSignatureBuilder {
271    key: VapidKey,
272}
273
274impl PartialVapidSignatureBuilder {
275    /// Adds the VAPID subscription info for a particular client.
276    pub fn add_sub_info(self, subscription_info: &SubscriptionInfo) -> VapidSignatureBuilder<'_> {
277        VapidSignatureBuilder {
278            key: self.key,
279            claims: jwt_simple::prelude::Claims::with_custom_claims(BTreeMap::new(), Duration::from_hours(12)),
280            subscription_info,
281        }
282    }
283
284    /// Gets the uncompressed public key bytes derived from the private key used for this VAPID signature.
285    ///
286    /// Base64 encode these bytes to get the key to send to the client.
287    pub fn get_public_key(&self) -> Vec<u8> {
288        self.key.public_key()
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use ct_codecs::{Base64UrlSafeNoPadding, Encoder};
295
296    use crate::{message::SubscriptionInfo, vapid::VapidSignatureBuilder};
297
298    static PRIVATE_PEM: &[u8] = include_bytes!("../../resources/vapid_test_key.pem");
299    static PRIVATE_DER: &[u8] = include_bytes!("../../resources/vapid_test_key.der");
300    static PRIVATE_BASE64: &str = "IQ9Ur0ykXoHS9gzfYX0aBjy9lvdrjx_PFUXmie9YRcY";
301
302    fn example_subscription_info() -> SubscriptionInfo {
303        serde_json::from_value(
304                serde_json::json!({
305                    "endpoint": "https://updates.push.services.mozilla.com/wpush/v2/gAAAAABaso4Vajy4STM25r5y5oFfyN451rUmES6mhQngxABxbZB5q_o75WpG25oKdrlrh9KdgWFKdYBc-buLPhvCTqR5KdsK8iCZHQume-ndtZJWKOgJbQ20GjbxHmAT1IAv8AIxTwHO-JTQ2Np2hwkKISp2_KUtpnmwFzglLP7vlCd16hTNJ2I",
306                    "keys": {
307                        "auth": "sBXU5_tIYz-5w7G2B25BEw",
308                        "p256dh": "BH1HTeKM7-NwaLGHEqxeu2IamQaVVLkcsFHPIHmsCnqxcBHPQBprF41bEMOr3O1hUQ2jU1opNEm1F_lZV_sxMP8"
309                    }
310                })
311            ).unwrap()
312    }
313
314    #[test]
315    fn test_builder_from_pem() {
316        let subscription_info = example_subscription_info();
317        let builder = VapidSignatureBuilder::from_pem(PRIVATE_PEM, &subscription_info).unwrap();
318        let signature = builder.build().unwrap();
319
320        assert_eq!(
321            "BMo1HqKF6skMZYykrte9duqYwBD08mDQKTunRkJdD3sTJ9E-yyN6sJlPWTpKNhp-y2KeS6oANHF-q3w37bClb7U",
322            Base64UrlSafeNoPadding::encode_to_string(&signature.auth_k).unwrap()
323        );
324
325        assert!(!signature.auth_t.is_empty());
326    }
327
328    #[test]
329    fn test_builder_from_der() {
330        let subscription_info = example_subscription_info();
331        let builder = VapidSignatureBuilder::from_der(PRIVATE_DER, &subscription_info).unwrap();
332        let signature = builder.build().unwrap();
333
334        assert_eq!(
335            "BMo1HqKF6skMZYykrte9duqYwBD08mDQKTunRkJdD3sTJ9E-yyN6sJlPWTpKNhp-y2KeS6oANHF-q3w37bClb7U",
336            Base64UrlSafeNoPadding::encode_to_string(&signature.auth_k).unwrap()
337        );
338
339        assert!(!signature.auth_t.is_empty());
340    }
341
342    #[test]
343    fn test_builder_from_base64() {
344        let subscription_info = example_subscription_info();
345        let builder = VapidSignatureBuilder::from_base64(PRIVATE_BASE64, &subscription_info).unwrap();
346        let signature = builder.build().unwrap();
347
348        assert_eq!(
349            "BMjQIp55pdbU8pfCBKyXcZjlmER_mXt5LqNrN1hrXbdBS5EnhIbMu3Au-RV53iIpztzNXkGI56BFB1udQ8Bq_H4",
350            Base64UrlSafeNoPadding::encode_to_string(&signature.auth_k).unwrap()
351        );
352
353        assert!(!signature.auth_t.is_empty());
354    }
355}