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}