Skip to main content

web_push_native/
lib.rs

1//! This crate implements "Generic Event Delivery Using Http Push" (web-push)
2//! according to [RFC8030](https://www.rfc-editor.org/rfc/rfc8030).
3//!
4//! # Example
5//!
6//! This example shows how to use the [`WebPushBuilder`] to create a HTTP push
7//! request to a single hard-coded client.
8//!
9//! For most projects you will need to implement some form of state management
10//! to send messages to all of your clients. You are expected to create one
11//! [`WebPushBuilder`] for each client you want to send messages to, but can
12//! reuse the same builder for multiple push requests to the same client.
13//!
14//! Please see the
15//! [`/example`](https://github.com/leotaku/web-push-native/tree/master/example)
16//! directory on GitHub for a more fully-featured example which presents how to
17//! setup an [`axum`] web server in combination with this library to expose a
18//! simple HTTP API for sending web-push notifications.
19//!
20//! ```
21//! use base64ct::{Base64UrlUnpadded, Encoding as _};
22//! use web_push_native::{
23//!     jwt_simple::algorithms::ES256KeyPair, p256::PublicKey, Auth, Error, WebPushBuilder,
24//! };
25//!
26//! // Placeholders for variables provided by individual clients. In most cases,
27//! // these will be retrieved in-browser by calling `pushManager.subscribe` on
28//! // a service worker registration object.
29//! const ENDPOINT: &str = "https://example.com/";
30//! const P256DH: &str =
31//!     "BLn9b-VR0ca83knDNZ32dCHGyjJp-1riX9ZTN40MqV8K_LpQmLqxC_DoHvqvFXO_nGdAB4W9dogZb_sM-uV4JbY";
32//! const AUTH: &str = "_ordMnz7uTCmrpBTeUV4Bw";
33//!
34//! // Placeholder for your private VAPID key. Keep this private and out of your
35//! // source tree in real projects!
36//! const VAPID_PRIVATE: &str = "RS0WdYWWo1HajXg3NZR1olzCf31i-ZBGDkFyCs7j1jw";
37//!
38//! let key_pair = ES256KeyPair::from_bytes(&Base64UrlUnpadded::decode_vec(VAPID_PRIVATE)?)?;
39//! let builder = WebPushBuilder::new(
40//!     ENDPOINT.parse()?,
41//!     PublicKey::from_sec1_bytes(&Base64UrlUnpadded::decode_vec(P256DH)?)?,
42//!     Auth::clone_from_slice(&Base64UrlUnpadded::decode_vec(AUTH)?),
43//! )
44//! .with_vapid(&key_pair, "mailto:john.doe@example.com");
45//!
46//! builder.build("hello!")?;
47//! #
48//! # Ok::<_, Box<dyn std::error::Error>>(())
49//! ```
50//!
51//! [`axum`]: https://docs.rs/axum
52
53#[cfg(feature = "serialization")]
54mod serde_;
55#[cfg(test)]
56mod tests;
57#[cfg(feature = "vapid")]
58pub mod vapid;
59
60use std::time::Duration;
61
62use aes_gcm::aead::{
63    generic_array::{typenum::U16, GenericArray},
64    rand_core::RngCore,
65    OsRng,
66};
67use hkdf::Hkdf;
68use http::{self, header, Request, Uri};
69#[cfg(feature = "vapid")]
70pub use jwt_simple;
71pub use p256;
72use p256::elliptic_curve::sec1::ToEncodedPoint;
73use sha2::Sha256;
74
75/// Error type for HTTP push failure modes
76#[derive(Debug)]
77pub enum Error {
78    /// Internal ECE error
79    ECE(ece_native::Error),
80    /// Internal error coming from an http auth provider
81    Extension(Box<dyn std::error::Error + Send + Sync + 'static>),
82}
83
84impl std::error::Error for Error {}
85
86impl std::fmt::Display for Error {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        match self {
89            Error::ECE(ece) => write!(f, "ECE: {}", ece),
90            Error::Extension(ext) => write!(f, "Extension: {}", ext),
91        }
92    }
93}
94
95/// HTTP push authentication secret
96pub type Auth = GenericArray<u8, U16>;
97
98/// Reusable builder for HTTP push requests
99#[derive(Clone, Debug)]
100pub struct WebPushBuilder<A = ()> {
101    endpoint: Uri,
102    valid_duration: Duration,
103    ua_public: p256::PublicKey,
104    ua_auth: Auth,
105    #[cfg_attr(not(feature = "vapid"), allow(dead_code))]
106    http_auth: A,
107}
108
109impl WebPushBuilder {
110    /// Creates a new [`WebPushBuilder`] factory for HTTP push requests.
111    ///
112    /// Requests generated using this factory will have a valid  duration of 12
113    /// hours and no VAPID signature.
114    ///
115    /// Most providers accepting HTTP push requests will require a valid VAPID
116    /// signature, so you will most likely want to add one using
117    /// [`WebPushBuilder::with_vapid`].
118    pub fn new(endpoint: Uri, ua_public: p256::PublicKey, ua_auth: Auth) -> Self {
119        Self {
120            endpoint,
121            ua_public,
122            ua_auth,
123            valid_duration: Duration::from_secs(12 * 60 * 60),
124            http_auth: (),
125        }
126    }
127
128    /// Sets the valid duration for generated HTTP push requests.
129    pub fn with_valid_duration(self, valid_duration: Duration) -> Self {
130        Self {
131            valid_duration,
132            ..self
133        }
134    }
135
136    /// Sets the VAPID signature header for generated HTTP push requests.
137    #[cfg(feature = "vapid")]
138    pub fn with_vapid<'a>(
139        self,
140        vapid_kp: &'a jwt_simple::algorithms::ES256KeyPair,
141        contact: &'a str,
142    ) -> WebPushBuilder<vapid::VapidAuthorization<'a>> {
143        WebPushBuilder {
144            endpoint: self.endpoint,
145            valid_duration: self.valid_duration,
146            ua_public: self.ua_public,
147            ua_auth: self.ua_auth,
148            http_auth: vapid::VapidAuthorization::new(vapid_kp, contact),
149        }
150    }
151}
152
153trait AddHeaders: Sized {
154    type Error: Into<Box<dyn std::error::Error + Sync + Send + 'static>>;
155
156    fn add_headers(
157        this: &WebPushBuilder<Self>,
158        builder: http::request::Builder,
159    ) -> Result<http::request::Builder, Self::Error>;
160}
161
162impl AddHeaders for () {
163    type Error = std::convert::Infallible;
164
165    fn add_headers(
166        _this: &WebPushBuilder<Self>,
167        builder: http::request::Builder,
168    ) -> Result<http::request::Builder, Self::Error> {
169        Ok(builder)
170    }
171}
172
173#[expect(private_bounds)]
174impl<A: AddHeaders> WebPushBuilder<A> {
175    /// Generates a new HTTP push request according to the
176    /// specifications of the builder.
177    pub fn build<T: Into<Vec<u8>>>(&self, body: T) -> Result<Request<Vec<u8>>, Error> {
178        let body = body.into();
179
180        let payload = encrypt(body, &self.ua_public, &self.ua_auth)?;
181        let builder = Request::builder()
182            .uri(self.endpoint.clone())
183            .method(http::method::Method::POST)
184            .header("TTL", self.valid_duration.as_secs())
185            .header(header::CONTENT_ENCODING, "aes128gcm")
186            .header(header::CONTENT_TYPE, "application/octet-stream")
187            .header(header::CONTENT_LENGTH, payload.len());
188
189        let builder =
190            AddHeaders::add_headers(self, builder).map_err(|it| Error::Extension(it.into()))?;
191
192        Ok(builder
193            .body(payload)
194            .expect("builder arguments are always well-defined"))
195    }
196}
197
198/// Lower-level encryption used for HTTP push request content
199pub fn encrypt(
200    message: Vec<u8>,
201    ua_public: &p256::PublicKey,
202    ua_auth: &Auth,
203) -> Result<Vec<u8>, Error> {
204    let mut salt = [0u8; 16];
205    OsRng.fill_bytes(&mut salt);
206    let as_secret = p256::SecretKey::random(&mut OsRng);
207    encrypt_predictably(salt, message, &as_secret, ua_public, ua_auth).map_err(Error::ECE)
208}
209
210fn encrypt_predictably(
211    salt: [u8; 16],
212    message: Vec<u8>,
213    as_secret: &p256::SecretKey,
214    ua_public: &p256::PublicKey,
215    ua_auth: &Auth,
216) -> Result<Vec<u8>, ece_native::Error> {
217    let as_public = as_secret.public_key();
218    let shared = p256::ecdh::diffie_hellman(as_secret.to_nonzero_scalar(), ua_public.as_affine());
219
220    let ikm = compute_ikm(ua_auth, &shared, ua_public, &as_public);
221    let keyid = as_public.as_affine().to_encoded_point(false);
222    let encrypted_record_length = (message.len() + 17)
223        .try_into()
224        .map_err(|_| ece_native::Error::RecordLengthInvalid)?;
225
226    ece_native::encrypt(
227        ikm,
228        salt,
229        keyid,
230        Some(message).into_iter(),
231        encrypted_record_length,
232    )
233}
234
235/// Lower-level decryption used for HTTP push request content
236pub fn decrypt(
237    encrypted_message: Vec<u8>,
238    as_secret: &p256::SecretKey,
239    ua_auth: &Auth,
240) -> Result<Vec<u8>, Error> {
241    let keyid = view_keyid(&encrypted_message).map_err(Error::ECE)?;
242    let ua_public = p256::PublicKey::from_sec1_bytes(keyid)
243        .map_err(|_| ece_native::Error::Aes128Gcm)
244        .map_err(Error::ECE)?;
245    let shared = p256::ecdh::diffie_hellman(as_secret.to_nonzero_scalar(), ua_public.as_affine());
246
247    let ikm = compute_ikm(ua_auth, &shared, &as_secret.public_key(), &ua_public);
248
249    ece_native::decrypt(ikm, encrypted_message).map_err(Error::ECE)
250}
251
252fn compute_ikm(
253    auth: &Auth,
254    shared: &p256::ecdh::SharedSecret,
255    ua_public: &p256::PublicKey,
256    as_public: &p256::PublicKey,
257) -> [u8; 32] {
258    let mut info = Vec::new();
259    info.extend_from_slice(&b"WebPush: info"[..]);
260    info.push(0u8);
261    info.extend_from_slice(ua_public.as_affine().to_encoded_point(false).as_bytes());
262    info.extend_from_slice(as_public.as_affine().to_encoded_point(false).as_bytes());
263
264    let mut okm = [0u8; 32];
265    let hk = Hkdf::<Sha256>::new(Some(auth), shared.raw_secret_bytes().as_ref());
266    hk.expand(&info, &mut okm)
267        .expect("okm length is always 32 bytes, cannot be too large");
268
269    okm
270}
271
272fn view_keyid(encrypted_message: &[u8]) -> Result<&[u8], ece_native::Error> {
273    if encrypted_message.len() < 21 {
274        return Err(ece_native::Error::HeaderLengthInvalid);
275    }
276
277    let idlen: usize = encrypted_message[20].into();
278    if encrypted_message[21..].len() < idlen {
279        return Err(ece_native::Error::KeyIdLengthInvalid);
280    }
281
282    Ok(&encrypted_message[21..21 + idlen])
283}