diff --git a/Cargo.toml b/Cargo.toml index f494bb2c..4f63a408 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "web-push" description = "Web push notification client with support for http-ece encryption and VAPID authentication." -version = "0.7.3" +version = "0.8.0" authors = ["Julius de Bruijn "] license = "Apache-2.0" homepage = "https://github.com/pimeys/rust-web-push" @@ -15,23 +15,30 @@ edition = "2018" [badges] travis-ci = { repository = "pimeys/rust-web-push" } +[features] +default = ["isahc"] +hyper-client = ["hyper", "hyper-tls"] #use features = ["hyper-client"], default-features = false for about 300kb size decrease. + [dependencies] futures = "^0.3" -hyper = {version = "^0.14", features = ["client", "http1"]} -hyper-tls = "^0.5" +hyper = { version = "^0.14", features = ["client", "http1"], optional = true } +hyper-tls = { version = "^0.5", optional = true } +isahc = { version = "^1.4.0", optional = true } http = "^0.2" serde = "^1.0" serde_json = "^1.0" serde_derive = "^1.0" ring = "^0.16" +ece = "^2.1.0" native-tls = "^0.2" base64 = "^0.13" openssl = "^0.10" -time = {version = "^0.2", features = ["std"]} +time = { version = "^0.2", features = ["std"] } lazy_static = "^1.4" chrono = "^0.4" log = "^0.4" [dev-dependencies] argparse = "^0.2" -tokio = { version = "^1.1", features = ["macros","rt-multi-thread"] } +regex = "^1.5" +tokio = { version = "^1.1", features = ["macros", "rt-multi-thread"] } diff --git a/README.md b/README.md index 4c681e57..4fa2c36f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Rust Web Push ============= [![Cargo tests](https://github.com/pimeys/rust-web-push/actions/workflows/test.yml/badge.svg)](https://github.com/pimeys/rust-web-push/actions/workflows/test.yml) -[![crates.io](http://meritbadge.herokuapp.com/web_push)](https://crates.io/crates/web_push) +[![crates.io](https://img.shields.io/crates/d/web-push)](https://crates.io/crates/web_push) [![docs.rs](https://docs.rs/web-push/badge.svg)](https://docs.rs/web-push) [Matrix chat](https://matrix.to/#/#rust-push:nauk.io?via=nauk.io&via=matrix.org&via=shine.horse) @@ -11,13 +11,25 @@ Web push notification sender. ## Requirements -Needs a Tokio executor version 0.2 or later and Rust compiler version 1.39.0 or later. +Any async executor for use with client. + +## Migration to v0.8 + +- The `aesgcm` variant of `ContentEncoding` has been removed. Aes128Gcm support was added in v0.8, so all uses + of `ContentEncoding::aesgcm` can simply be changed to `ContentEncoding::Aes128Gcm` with no change to functionality. + This will add support for Edge in the process. + +- `WebPushClient::new()` now returns a `Result`, as the default client now has a fallible constructor. Please handle + this error in the case of resource starvation. + +- All GCM/FCM support has been removed. If you relied on this functionality, consider + the [fcm crate](https://crates.io/crates/fcm). If you just require web push, you will need to use VAPID to send + payloads. See below for info. ## Usage -To send a web push from command line, first subscribe to receive push -notifications with your browser and store the subscription info into a json -file. It should have the following content: +To send a web push from command line, first subscribe to receive push notifications with your browser and store the +subscription info into a json file. It should have the following content: ``` json { @@ -30,28 +42,56 @@ file. It should have the following content: ``` Google has -[good instructions](https://developers.google.com/web/updates/2015/03/push-notifications-on-the-open-web) for -building a frontend to receive notifications. +[good instructions](https://developers.google.com/web/fundamentals/push-notifications/subscribing-a-user) for building a +frontend to receive notifications. Store the subscription info to `examples/test.json` and send a notification with -`cargo run --example simple_send -- -f examples/test.json -p "It works!"`. If -using Google Chrome, you need to register yourself -into [Firebase](https://firebase.google.com/) and provide a GCM API Key with -parameter `-k GCM_API_KEY`. +`cargo run --example simple_send -- -f examples/test.json -p "It works!"`. -Examples +Example -------- -To see it used in a real project, take a look to the [XORC -Notifications](https://github.com/xray-tech/xorc-notifications), which is a -full-fledged consumer for sending push notifications. +```rust +use web_push::*; +use base64::URL_SAFE; +use std::fs::File; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let endpoint = "https://updates.push.services.mozilla.com/wpush/v1/..."; + let p256dh = "key_from_browser_as_base64"; + let auth = "auth_from_browser_as_base64"; + + //You would likely get this by deserializing a browser `pushSubscription` object. + let subscription_info = SubscriptionInfo::new( + endpoint, + p256dh, + auth + ); + + //Read signing material for payload. + let file = File::open("private.pem").unwrap(); + let mut sig_builder = VapidSignatureBuilder::from_pem(file, &subscription_info)?.build()?; + + //Now add payload and encrypt. + let mut builder = WebPushMessageBuilder::new(&subscription_info)?; + let content = "Encrypted payload to be sent in the notification".as_bytes(); + builder.set_payload(ContentEncoding::Aes128Gcm, content); + builder.set_vapid_signature(sig_builder); + + let client = WebPushClient::new()?; + + //Finally, send the notification! + client.send(builder.build()?).await?; + Ok(()) +} + ``` VAPID ----- -VAPID authentication prevents unknown sources sending notifications to the -client and allows sending notifications to Chrome without signing in to Firebase -and providing a GCM API key. +VAPID authentication prevents unknown sources sending notifications to the client and is required by all current +browsers when sending a payload. The private key to be used by the server can be generated with OpenSSL: @@ -59,54 +99,57 @@ The private key to be used by the server can be generated with OpenSSL: openssl ecparam -genkey -name prime256v1 -out private_key.pem ``` -To derive a public key from the just-generated private key, to be used in the -JavaScript client: +To derive a public key from the just-generated private key, to be used in the JavaScript client: ``` openssl ec -in private_key.pem -pubout -outform DER|tail -c 65|base64|tr '/+' '_-'|tr -d '\n' ``` -The signature is created with `VapidSignatureBuilder`. It automatically adds the -required claims `aud` and `exp`. Adding these claims to the builder manually -will override the default values. +The signature is created with `VapidSignatureBuilder`. It automatically adds the required claims `aud` and `exp`. Adding +these claims to the builder manually will override the default values. Overview -------- -Currently implements -[HTTP-ECE Draft-3](https://datatracker.ietf.org/doc/draft-ietf-httpbis-encryption-encoding/03/?include_text=1) -content encryption for notification payloads. The client requires -[Tokio](https://tokio.rs) for asynchronious requests. The modular design allows -an easy extension for the upcoming aes128gcm when the browsers are getting -support for it. +Currently, implements +[RFC8188](https://datatracker.ietf.org/doc/html/rfc8188) content encryption for notification payloads. This is done by +delegating encryption to mozilla's [ece crate](https://crates.io/crates/ece). Our security is thus tied +to [theirs](https://github.com/mozilla/rust-ece/issues/18). The default client is built +on [isahc](https://crates.io/crates/isahc), but can be swapped out with a hyper based client using the +`hyper-client` feature. Custom clients can be made using the `request_builder` module. -Tested with Google's and Mozilla's push notification services. +Library tested with Google's and Mozilla's push notification services. Also verified to work on Edge. Debugging -------- If you get an error or the push notification doesn't work you can try to debug using the following instructions: Add the following to your Cargo.toml: + ```cargo log = "0.4" pretty_env_logger = "0.3" ``` Add the following to your main.rs: + ```rust extern crate pretty_env_logger; + // ... fn main() { - pretty_env_logger::init(); - // ... + pretty_env_logger::init(); + // ... } ``` Or use any other logging library compatible with https://docs.rs/log/ Then run your program with the following environment variables: + ```bash RUST_LOG="web_push::client=trace" cargo run ``` -This should print some more information about the requests to the push service which may aid you or somebody else in finding the error. +This should print some more information about the requests to the push service which may aid you or somebody else in +finding the error. diff --git a/examples/simple_send.rs b/examples/simple_send.rs index affedeba..3ea52a9c 100644 --- a/examples/simple_send.rs +++ b/examples/simple_send.rs @@ -5,24 +5,27 @@ use web_push::*; #[tokio::main] async fn main() -> Result<(), Box> { let mut subscription_info_file = String::new(); - let mut gcm_api_key: Option = None; let mut vapid_private_key: Option = None; let mut push_payload: Option = None; + let mut encoding: Option = None; let mut ttl: Option = None; { let mut ap = ArgumentParser::new(); ap.set_description("A web push sender"); - ap.refer(&mut gcm_api_key) - .add_option(&["-k", "--gcm_api_key"], StoreOption, "Google GCM API Key"); - ap.refer(&mut vapid_private_key).add_option( &["-v", "--vapid_key"], StoreOption, "A NIST P256 EC private key to create a VAPID signature", ); + ap.refer(&mut encoding).add_option( + &["-e", "--encoding"], + StoreOption, + "Content Encoding Scheme : currently only accepts 'aes128gcm'. Defaults to 'aes128gcm'. Reserved for future standards.", + ); + ap.refer(&mut subscription_info_file).add_option( &["-f", "--subscription_info_file"], Store, @@ -42,16 +45,18 @@ async fn main() -> Result<(), Box let mut contents = String::new(); file.read_to_string(&mut contents).unwrap(); + let ece_scheme = match encoding.as_deref() { + Some("aes128gcm") => ContentEncoding::Aes128Gcm, + None => ContentEncoding::Aes128Gcm, + Some(_) => panic!("Content encoding can only be 'aes128gcm'"), + }; + let subscription_info: SubscriptionInfo = serde_json::from_str(&contents).unwrap(); let mut builder = WebPushMessageBuilder::new(&subscription_info).unwrap(); if let Some(ref payload) = push_payload { - builder.set_payload(ContentEncoding::AesGcm, payload.as_bytes()); - } - - if let Some(ref gcm_key) = gcm_api_key { - builder.set_gcm_key(gcm_key); + builder.set_payload(ece_scheme, payload.as_bytes()); } if let Some(time) = ttl { @@ -70,10 +75,10 @@ async fn main() -> Result<(), Box let signature = sig_builder.build().unwrap(); builder.set_vapid_signature(signature); - builder.set_payload(ContentEncoding::AesGcm, "test".as_bytes()); + builder.set_payload(ContentEncoding::Aes128Gcm, "test".as_bytes()); }; - let client = WebPushClient::new(); + let client = WebPushClient::new()?; let response = client.send(builder.build()?).await?; println!("Sent: {:?}", response); diff --git a/src/client.rs b/src/client.rs deleted file mode 100644 index fdf9a4c9..00000000 --- a/src/client.rs +++ /dev/null @@ -1,91 +0,0 @@ -use crate::error::{RetryAfter, WebPushError}; -use crate::message::{WebPushMessage, WebPushService}; -use crate::services::{autopush, firebase}; -use http::header::{CONTENT_LENGTH, RETRY_AFTER}; -use hyper::{body::HttpBody, client::HttpConnector, Body, Client, Request as HttpRequest}; -use hyper_tls::HttpsConnector; -use std::future::Future; - -/// An async client for sending the notification payload. -pub struct WebPushClient { - client: Client>, -} - -impl Default for WebPushClient { - fn default() -> Self { - Self::new() - } -} - -impl WebPushClient { - pub fn new() -> WebPushClient { - WebPushClient { - client: Client::builder().build(HttpsConnector::new()), - } - } - - /// Sends a notification. Never times out. - pub fn send(&self, message: WebPushMessage) -> impl Future> + 'static { - trace!("Message: {:?}", message); - let service = message.service.clone(); - - let request: HttpRequest = match service { - WebPushService::Firebase => { - trace!("Building firebase request"); - firebase::build_request(message) - } - _ => { - trace!("Building autopush request"); - autopush::build_request(message) - } - }; - - trace!("Request: {:?}", request); - - let requesting = self.client.request(request); - - async move { - let response = requesting.await?; - trace!("Response: {:?}", response); - - let retry_after = response - .headers() - .get(RETRY_AFTER) - .and_then(|ra| ra.to_str().ok()) - .and_then(|ra| RetryAfter::from_str(ra)); - - let response_status = response.status(); - trace!("Response status: {}", response_status); - - let content_length: usize = response - .headers() - .get(CONTENT_LENGTH) - .and_then(|s| s.to_str().ok()) - .and_then(|s| s.parse().ok()) - .unwrap_or(0); - - let mut body: Vec = Vec::with_capacity(content_length); - let mut chunks = response.into_body(); - - while let Some(chunk) = chunks.data().await { - body.extend(&chunk?); - } - trace!("Body: {:?}", body); - - trace!("Body text: {:?}", std::str::from_utf8(&body)); - - let response = match service { - WebPushService::Firebase => firebase::parse_response(response_status, body.to_vec()), - _ => autopush::parse_response(response_status, body.to_vec()), - }; - - debug!("Response: {:?}", response); - - if let Err(WebPushError::ServerError(None)) = response { - Err(WebPushError::ServerError(retry_after)) - } else { - Ok(response?) - } - } - } -} diff --git a/src/clients/hyper_client.rs b/src/clients/hyper_client.rs new file mode 100644 index 00000000..f74a1f14 --- /dev/null +++ b/src/clients/hyper_client.rs @@ -0,0 +1,84 @@ + +use http::header::{CONTENT_LENGTH, RETRY_AFTER}; +use hyper::{body::HttpBody, client::HttpConnector, Body, Client, Request as HttpRequest}; +use hyper_tls::HttpsConnector; + +use crate::clients::request_builder; +use crate::error::{RetryAfter, WebPushError}; +use crate::message::WebPushMessage; +use std::convert::Infallible; + +/// An async client for sending the notification payload. +/// +/// This client is [`hyper`](https://crates.io/crates/hyper) based, and will only work in Tokio contexts. +pub struct WebPushClient { + client: Client>, +} + +impl Default for WebPushClient { + fn default() -> Self { + Self::new().unwrap() + } +} + +impl WebPushClient { + + /// Creates a new client. + pub fn new() -> Result { + //This method can never fail, but returns error to match API of the isahc client. + Ok(WebPushClient { + client: Client::builder().build(HttpsConnector::new()), + }) + } + + /// Sends a notification. Never times out. + pub async fn send(&self, message: WebPushMessage) -> Result<(), WebPushError> { + trace!("Message: {:?}", message); + + let request: HttpRequest = request_builder::build_request(message); + + debug!("Request: {:?}", request); + + let requesting = self.client.request(request); + + let response = requesting.await?; + + trace!("Response: {:?}", response); + + let retry_after = response + .headers() + .get(RETRY_AFTER) + .and_then(|ra| ra.to_str().ok()) + .and_then(|ra| RetryAfter::from_str(ra)); + + let response_status = response.status(); + trace!("Response status: {}", response_status); + + let content_length: usize = response + .headers() + .get(CONTENT_LENGTH) + .and_then(|s| s.to_str().ok()) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + let mut body: Vec = Vec::with_capacity(content_length); + let mut chunks = response.into_body(); + + while let Some(chunk) = chunks.data().await { + body.extend(&chunk?); + } + trace!("Body: {:?}", body); + + trace!("Body text: {:?}", std::str::from_utf8(&body)); + + let response = request_builder::parse_response(response_status, body.to_vec()); + + debug!("Response: {:?}", response); + + if let Err(WebPushError::ServerError(None)) = response { + Err(WebPushError::ServerError(retry_after)) + } else { + Ok(response?) + } + } +} diff --git a/src/clients/isahc_client.rs b/src/clients/isahc_client.rs new file mode 100644 index 00000000..08d9dae8 --- /dev/null +++ b/src/clients/isahc_client.rs @@ -0,0 +1,83 @@ +use http::header::{CONTENT_LENGTH, RETRY_AFTER}; +use isahc::HttpClient; + +use crate::clients::request_builder; +use crate::error::{RetryAfter, WebPushError}; +use crate::message::WebPushMessage; +use futures::AsyncReadExt; + +/// An async client for sending the notification payload. This client is expensive to create, and +/// should be reused. +/// +/// This client is built on [`isahc`](https://crates.io/crates/isahc), and will therefore work on any async executor. +pub struct WebPushClient { + client: HttpClient, +} + +impl Default for WebPushClient { + fn default() -> Self { + Self::new().unwrap() + } +} + +impl WebPushClient { + /// Creates a new client. Can fail under resource depletion. + pub fn new() -> Result { + Ok(WebPushClient { + client: HttpClient::new()?, + }) + } + + /// Sends a notification. Never times out. + pub async fn send(&self, message: WebPushMessage) -> Result<(), WebPushError> { + trace!("Message: {:?}", message); + + let request = request_builder::build_request::(message); + + trace!("Request: {:?}", request); + + let requesting = self.client.send_async(request); + + let response = requesting.await?; + + trace!("Response: {:?}", response); + + let retry_after = response + .headers() + .get(RETRY_AFTER) + .and_then(|ra| ra.to_str().ok()) + .and_then(|ra| RetryAfter::from_str(ra)); + + let response_status = response.status(); + trace!("Response status: {}", response_status); + + let content_length: usize = response + .headers() + .get(CONTENT_LENGTH) + .and_then(|s| s.to_str().ok()) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + let mut body: Vec = Vec::with_capacity(content_length); + let mut chunks = response.into_body(); + + chunks + .read_to_end(&mut body) + .await + .map_err(|_| WebPushError::InvalidResponse)?; + + trace!("Body: {:?}", body); + + trace!("Body text: {:?}", std::str::from_utf8(&body)); + + let response = request_builder::parse_response(response_status, body.to_vec()); + + trace!("Response: {:?}", response); + + if let Err(WebPushError::ServerError(None)) = response { + Err(WebPushError::ServerError(retry_after)) + } else { + Ok(response?) + } + } +} diff --git a/src/clients/mod.rs b/src/clients/mod.rs new file mode 100644 index 00000000..2d7d4cf9 --- /dev/null +++ b/src/clients/mod.rs @@ -0,0 +1,13 @@ + +//! Contains implementations of web push clients. +//! +//! [`request_builder`] contains the functions used to send and consume push http messages. +//! This module should be consumed by each client, by using [`http`]'s flexible api. + +pub mod request_builder; + +#[cfg(feature = "hyper-client")] +pub mod hyper_client; + +#[cfg(not(feature = "hyper-client"))] +pub mod isahc_client; diff --git a/src/services/autopush.rs b/src/clients/request_builder.rs similarity index 62% rename from src/services/autopush.rs rename to src/clients/request_builder.rs index f5639ef0..ac40015c 100644 --- a/src/services/autopush.rs +++ b/src/clients/request_builder.rs @@ -1,6 +1,9 @@ +//! Functions used to send and consume push http messages. +//! This module can be used to build custom clients. + use crate::{error::WebPushError, message::WebPushMessage}; use http::header::{CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE}; -use hyper::{Body, Request, StatusCode}; +use http::{Request, StatusCode}; #[derive(Deserialize, Serialize, Debug, PartialEq)] struct ErrorInfo { @@ -10,7 +13,32 @@ struct ErrorInfo { message: String, } -pub fn build_request(message: WebPushMessage) -> Request { +/// Builds the request to send to the push service. +/// +/// This function is generic over the request body, this means that you can swap out client implementations +/// even if they use different body types. +/// +/// # Example +/// +/// ```no_run +/// # use web_push::{SubscriptionInfo, WebPushMessageBuilder}; +/// # use web_push::request_builder::build_request; +/// let info = SubscriptionInfo::new( +/// "http://google.com", +/// "BLMbF9ffKBiWQLCKvTHb6LO8Nb6dcUh6TItC455vu2kElga6PQvUmaFyCdykxY2nOSSL3yKgfbmFLRTUaGv4yV8", +/// "xS03Fi5ErfTNH_l9WHE9Ig", +/// ); +/// +/// let mut builder = WebPushMessageBuilder::new(&info).unwrap(); +/// +/// //Build the request for isahc +/// let request = build_request::(builder.build().unwrap()); +/// //Send using a http client +/// ``` +pub fn build_request(message: WebPushMessage) -> Request +where + T: From> + From<&'static str>, //This bound can be reduced to a &[u8] instead of str if needed +{ let mut builder = Request::builder() .method("POST") .uri(message.endpoint) @@ -33,6 +61,7 @@ pub fn build_request(message: WebPushMessage) -> Request { } } +/// Parses the response from the push service, and will return `Err` if the request was bad. pub fn parse_response(response_status: StatusCode, body: Vec) -> Result<(), WebPushError> { match response_status { status if status.is_success() => Ok(()), @@ -58,28 +87,32 @@ pub fn parse_response(response_status: StatusCode, body: Vec) -> Result<(), #[cfg(test)] mod tests { + use crate::clients::request_builder::*; use crate::error::WebPushError; use crate::http_ece::ContentEncoding; - use crate::message::{SubscriptionInfo, WebPushMessageBuilder}; - use crate::services::autopush::*; - use hyper::StatusCode; - use hyper::Uri; + use crate::message::{WebPushMessageBuilder}; + use http::Uri; #[test] fn builds_a_correct_request_with_empty_payload() { - let info = SubscriptionInfo::new( - "http://google.com", - "BLMbF9ffKBiWQLCKvTHb6LO8Nb6dcUh6TItC455vu2kElga6PQvUmaFyCdykxY2nOSSL3yKgfbmFLRTUaGv4yV8", - "xS03Fi5ErfTNH_l9WHE9Ig", - ); + + //This *was* a real token + let sub = json!({"endpoint":"https://fcm.googleapis.com/fcm/send/eKClHsXFm9E:APA91bH2x3gNOMv4dF1lQfCgIfOet8EngqKCAUS5DncLOd5hzfSUxcjigIjw9ws-bqa-KmohqiTOcgepAIVO03N39dQfkEkopubML_m3fyvF03pV9_JCB7SxpUjcFmBSVhCaWS6m8l7x", + "expirationTime":null, + "keys":{"p256dh": + "BGa4N1PI79lboMR_YrwCiCsgp35DRvedt7opHcf0yM3iOBTSoQYqQLwWxAfRKE6tsDnReWmhsImkhDF_DBdkNSU", + "auth":"EvcWjEgzr4rbvhfi3yds0A"} + }); + + let info = serde_json::from_value(sub).unwrap(); let mut builder = WebPushMessageBuilder::new(&info).unwrap(); builder.set_ttl(420); - let request = build_request(builder.build().unwrap()); + let request = build_request::(builder.build().unwrap()); let ttl = request.headers().get("TTL").unwrap().to_str().unwrap(); - let expected_uri: Uri = "http://google.com".parse().unwrap(); + let expected_uri: Uri = "fcm.googleapis.com".parse().unwrap(); assert_eq!("420", ttl); assert_eq!(expected_uri.host(), request.uri().host()); @@ -87,25 +120,29 @@ mod tests { #[test] fn builds_a_correct_request_with_payload() { - let info = SubscriptionInfo::new( - "http://google.com", - "BLMbF9ffKBiWQLCKvTHb6LO8Nb6dcUh6TItC455vu2kElga6PQvUmaFyCdykxY2nOSSL3yKgfbmFLRTUaGv4yV8", - "xS03Fi5ErfTNH_l9WHE9Ig", - ); + //This *was* a real token + let sub = json!({"endpoint":"https://fcm.googleapis.com/fcm/send/eKClHsXFm9E:APA91bH2x3gNOMv4dF1lQfCgIfOet8EngqKCAUS5DncLOd5hzfSUxcjigIjw9ws-bqa-KmohqiTOcgepAIVO03N39dQfkEkopubML_m3fyvF03pV9_JCB7SxpUjcFmBSVhCaWS6m8l7x", + "expirationTime":null, + "keys":{"p256dh": + "BGa4N1PI79lboMR_YrwCiCsgp35DRvedt7opHcf0yM3iOBTSoQYqQLwWxAfRKE6tsDnReWmhsImkhDF_DBdkNSU", + "auth":"EvcWjEgzr4rbvhfi3yds0A"} + }); + + let info = serde_json::from_value(sub).unwrap(); let mut builder = WebPushMessageBuilder::new(&info).unwrap(); - builder.set_payload(ContentEncoding::AesGcm, "test".as_bytes()); + builder.set_payload(ContentEncoding::Aes128Gcm, "test".as_bytes()); - let request = build_request(builder.build().unwrap()); + let request = build_request::(builder.build().unwrap()); let encoding = request.headers().get("Content-Encoding").unwrap().to_str().unwrap(); let length = request.headers().get("Content-Length").unwrap(); - let expected_uri: Uri = "http://google.com".parse().unwrap(); + let expected_uri: Uri = "fcm.googleapis.com".parse().unwrap(); - assert_eq!("3070", length); - assert_eq!("aesgcm", encoding); + assert_eq!("230", length); + assert_eq!("aes128gcm", encoding); assert_eq!(expected_uri.host(), request.uri().host()); } diff --git a/src/error.rs b/src/error.rs index 82de318e..aeb24029 100644 --- a/src/error.rs +++ b/src/error.rs @@ -73,12 +73,20 @@ impl From for WebPushError { } } +#[cfg(feature = "hyper-client")] impl From for WebPushError { fn from(_: hyper::Error) -> Self { Self::Unspecified } } +#[cfg(not(feature = "hyper-client"))] +impl From for WebPushError { + fn from(_: isahc::Error) -> Self { + Self::Unspecified + } +} + impl From for WebPushError { fn from(_: native_tls::Error) -> WebPushError { WebPushError::TlsError diff --git a/src/http_ece.rs b/src/http_ece.rs index 2f0c3a00..fac78fe2 100644 --- a/src/http_ece.rs +++ b/src/http_ece.rs @@ -1,83 +1,24 @@ use crate::error::WebPushError; use crate::message::WebPushPayload; use crate::vapid::VapidSignature; -use base64::{self, URL_SAFE_NO_PAD}; -use ring::rand::SecureRandom; -use ring::{ - aead::{self, BoundKey}, - agreement, hkdf, rand, -}; +use ece::encrypt; +/// Content encoding profiles. pub enum ContentEncoding { - AesGcm, + //Make sure this enum remains exhaustive as that allows for easier migrations to new versions. Aes128Gcm, } +/// Struct for handling payload encryption. pub struct HttpEce<'a> { peer_public_key: &'a [u8], peer_secret: &'a [u8], encoding: ContentEncoding, - rng: rand::SystemRandom, vapid_signature: Option, } -#[derive(Debug, PartialEq)] -struct EceKey(T); - -impl hkdf::KeyType for EceKey { - fn len(&self) -> usize { - self.0 - } -} - -impl From>> for EceKey> { - fn from(okm: hkdf::Okm>) -> Self { - let mut r = vec![0u8; okm.len().0]; - okm.fill(&mut r).unwrap(); - EceKey(r) - } -} - -#[derive(Debug, PartialEq, Default)] -struct EceNonce { - used: bool, - nonce: Vec, -} - -impl EceNonce { - fn fill(&mut self, nonce: Vec) { - self.nonce = nonce; - self.used = false; - } -} - -impl aead::NonceSequence for EceNonce { - fn advance(&mut self) -> Result { - if self.used { - return Err(ring::error::Unspecified); - } - - let mut nonce = [0u8; 12]; - - for (i, n) in self.nonce.iter().enumerate() { - if i >= 12 { - return Err(ring::error::Unspecified); - } - - nonce[i] = *n; - } - - self.used = true; - - Ok(aead::Nonce::assume_unique_for_key(nonce)) - } -} - impl<'a> HttpEce<'a> { - /// Create a new encryptor. The content encoding has preliminary support for - /// Aes128Gcm, which is the 8th draft of the Encrypted Content-Encoding, but - /// currently using it will return an error when trying to encrypt. There is - /// no real support yet for the encoding in web browsers. + /// Create a new encryptor. /// /// `peer_public_key` is the `p256dh` and `peer_secret` the `auth` from /// browser subscription info. @@ -88,7 +29,6 @@ impl<'a> HttpEce<'a> { vapid_signature: Option, ) -> HttpEce<'a> { HttpEce { - rng: rand::SystemRandom::new(), peer_public_key, peer_secret, encoding, @@ -104,126 +44,45 @@ impl<'a> HttpEce<'a> { return Err(WebPushError::PayloadTooLarge); } - let private_key = agreement::EphemeralPrivateKey::generate(&agreement::ECDH_P256, &self.rng)?; - let public_key = private_key.compute_public_key()?; - let mut salt_bytes = [0u8; 16]; - - self.rng.fill(&mut salt_bytes)?; - let peer_public_key = agreement::UnparsedPublicKey::new(&agreement::ECDH_P256, self.peer_public_key); - - agreement::agree_ephemeral( - private_key, - &peer_public_key, - WebPushError::Unspecified, - |shared_secret| match self.encoding { - ContentEncoding::AesGcm => { - let mut payload = vec![0; 3054]; - front_pad(content, &mut payload); - - self.aes_gcm(shared_secret, public_key.as_ref(), &salt_bytes, &mut payload)?; - - Ok(WebPushPayload { - content: payload.to_vec(), - crypto_headers: self.generate_headers(public_key.as_ref(), &salt_bytes), - content_encoding: "aesgcm", - }) + //Add more encoding standards to this match as they are created. + match self.encoding { + ContentEncoding::Aes128Gcm => { + let result = encrypt(self.peer_public_key, self.peer_secret, content); + + let mut headers = Vec::new(); + + if let Some(signature) = &self.vapid_signature { + headers.push(( + "Authorization", + format!( + "vapid t={}, k={}", + signature.auth_t, + base64::encode_config(&signature.auth_k, base64::URL_SAFE_NO_PAD) + ), + )); } - ContentEncoding::Aes128Gcm => Err(WebPushError::NotImplemented), - }, - ) - } - - pub fn generate_headers(&self, public_key: &'a [u8], salt: &'a [u8]) -> Vec<(&'static str, String)> { - let mut crypto_headers = Vec::new(); - - let mut crypto_key = format!("dh={}", base64::encode_config(public_key, URL_SAFE_NO_PAD)); - - if let Some(ref signature) = self.vapid_signature { - crypto_key = format!("{}; p256ecdsa={}", crypto_key, signature.auth_k); - - let sig_s: String = signature.to_string(); - crypto_headers.push(("Authorization", sig_s)); - }; - - crypto_headers.push(("Crypto-Key", crypto_key)); - crypto_headers.push(( - "Encryption", - format!("salt={}", base64::encode_config(&salt, URL_SAFE_NO_PAD)), - )); - - crypto_headers - } - - /// The aesgcm encrypted content-encoding, draft 3. - pub fn aes_gcm( - &self, - shared_secret: &'a [u8], - as_public_key: &'a [u8], - salt_bytes: &'a [u8], - payload: &'a mut Vec, - ) -> Result<(), WebPushError> { - let mut context = Vec::with_capacity(140); - - context.extend_from_slice("P-256\0".as_bytes()); - context.push((self.peer_public_key.len() >> 8) as u8); - context.push((self.peer_public_key.len() & 0xff) as u8); - context.extend_from_slice(self.peer_public_key); - context.push((as_public_key.len() >> 8) as u8); - context.push((as_public_key.len() & 0xff) as u8); - context.extend_from_slice(as_public_key); - - let client_auth_secret = hkdf::Salt::new(hkdf::HKDF_SHA256, &self.peer_secret); - let salt = hkdf::Salt::new(hkdf::HKDF_SHA256, salt_bytes); - - let EceKey(prk) = client_auth_secret - .extract(shared_secret) - .expand(&[&"Content-Encoding: auth\0".as_bytes()], EceKey(32)) - .unwrap() - .into(); - - let mut cek_info = Vec::with_capacity(165); - cek_info.extend_from_slice("Content-Encoding: aesgcm\0".as_bytes()); - cek_info.extend_from_slice(&context); - let EceKey(content_encryption_key) = salt.extract(&prk).expand(&[&cek_info], EceKey(16)).unwrap().into(); - - let mut nonce_info = Vec::with_capacity(164); - nonce_info.extend_from_slice("Content-Encoding: nonce\0".as_bytes()); - nonce_info.extend_from_slice(&context); - - let EceKey(nonce_bytes) = salt.extract(&prk).expand(&[&nonce_info], EceKey(12)).unwrap().into(); - - let mut nonce = EceNonce::default(); - nonce.fill(nonce_bytes); - - let unbound_key = aead::UnboundKey::new(&aead::AES_128_GCM, &content_encryption_key)?; - let mut sealing_key = aead::SealingKey::new(unbound_key, nonce); - - sealing_key.seal_in_place_append_tag(aead::Aad::empty(), payload)?; - - Ok(()) - } -} - -fn front_pad(payload: &[u8], output: &mut [u8]) { - let payload_len = payload.len(); - let max_payload = output.len() - 2; - let padding_size = max_payload - payload.len(); - - output[0] = (padding_size >> 8) as u8; - output[1] = (padding_size & 0xff) as u8; - - for i in 0..payload_len { - output[padding_size + i + 2] = payload[i]; + match result { + Ok(data) => Ok(WebPushPayload { + content: data, + crypto_headers: headers, + content_encoding: "aes128gcm", + }), + _ => Err(WebPushError::InvalidCryptoKeys), + } + } + } } } #[cfg(test)] mod tests { use crate::error::WebPushError; - use crate::http_ece::{front_pad, ContentEncoding, HttpEce}; - use crate::vapid::VapidSignature; - use base64::{self, URL_SAFE, URL_SAFE_NO_PAD}; + use crate::http_ece::{ContentEncoding, HttpEce}; + use crate::VapidSignature; + use crate::WebPushPayload; + use base64::{self, URL_SAFE}; + use regex::Regex; #[test] fn test_payload_too_big() { @@ -233,132 +92,64 @@ mod tests { ) .unwrap(); let auth = base64::decode_config("xS03Fj5ErfTNH_l9WHE9Ig", URL_SAFE).unwrap(); - let http_ece = HttpEce::new(ContentEncoding::AesGcm, &p256dh, &auth, None); + let http_ece = HttpEce::new(ContentEncoding::Aes128Gcm, &p256dh, &auth, None); + //This content is one above limit. let content = [0u8; 3801]; assert_eq!(Err(WebPushError::PayloadTooLarge), http_ece.encrypt(&content)); } + /// Tests that the content encryption is properly reversible while using aes128gcm. #[test] - fn test_aes128gcm() { - let p256dh = base64::decode_config( - "BLMbF9ffKBiWQLCKvTHb6LO8Nb6dcUh6TItC455vu2kElga6PQvUmaFyCdykxY2nOSSL3yKgfbmFLRTUaGv4yV8", - URL_SAFE, - ) - .unwrap(); - let auth = base64::decode_config("xS03Fi5ErfTNH_l9WHE9Ig", URL_SAFE).unwrap(); + fn test_payload_encrypts_128() { + let (key, auth) = ece::generate_keypair_and_auth_secret().unwrap(); + let p_key = key.raw_components().unwrap(); + let p_key = p_key.public_key(); - let http_ece = HttpEce::new(ContentEncoding::Aes128Gcm, &p256dh, &auth, None); - let content = [0u8; 10]; - - assert_eq!(Err(WebPushError::NotImplemented), http_ece.encrypt(&content)); - } - - #[test] - fn test_aesgcm() { - let p256dh = base64::decode_config( - "BLMbF9ffKBiWQLCKvTHb6LO8Nb6dcUh6TItC455vu2kElga6PQvUmaFyCdykxY2nOSSL3yKgfbmFLRTUaGv4yV8", - URL_SAFE, - ) - .unwrap(); - let auth = base64::decode_config("xS03Fi5ErfTNH_l9WHE9Ig", URL_SAFE).unwrap(); - let http_ece = HttpEce::new(ContentEncoding::AesGcm, &p256dh, &auth, None); - let shared_secret = base64::decode_config("9vcttSQ8tq-Wi_lLQ_xA37tkYssMtJsdY6xENG5f1sE=", URL_SAFE).unwrap(); - let as_pubkey = base64::decode_config( - "BBXpqeMbtt1iwSoYzs7uRL-QVSKTAuAPrunJoNyW2wMKeVBUyNFCqbkmpVTZOVbqWpwpr_-6TpJvk1qT8T-iOYs=", - URL_SAFE, - ) - .unwrap(); - let salt_bytes = base64::decode_config("YMcMuxqRkchXwy7vMwNl1Q==", URL_SAFE).unwrap(); + let http_ece = HttpEce::new(ContentEncoding::Aes128Gcm, p_key, &auth, None); + let plaintext = "Hello world!"; + let ciphertext = http_ece.encrypt(plaintext.as_bytes()).unwrap(); - let mut payload = "This is test data. XXX".as_bytes().to_vec(); + assert_ne!(plaintext.as_bytes(), ciphertext.content); - http_ece - .aes_gcm(&shared_secret, &as_pubkey, &salt_bytes, &mut payload) - .unwrap(); assert_eq!( - "tmE7-emq6iasohjXNMue0i0vn5o7EIOyP-bKyDoM1teHLcLtg44", - base64::encode_config(&payload.to_vec(), URL_SAFE_NO_PAD) - ); - } - - #[test] - fn test_headers_with_vapid() { - let as_pubkey = base64::decode_config( - "BBXpqeMbtt1iwSoYzs7uRL-QVSKTAuAPrunJoNyW2wMKeVBUyNFCqbkmpVTZOVbqWpwpr_-6TpJvk1qT8T-iOYs=", - URL_SAFE, + String::from_utf8(ece::decrypt(&key.raw_components().unwrap(), &auth, &ciphertext.content).unwrap()) + .unwrap(), + plaintext ) - .unwrap(); - - let salt_bytes = base64::decode_config("YMcMuxqRkchXwy7vMwNl1Q==", URL_SAFE).unwrap(); + } + fn setup_payload(vapid_signature: Option, encoding: ContentEncoding) -> WebPushPayload { let p256dh = base64::decode_config( "BLMbF9ffKBiWQLCKvTHb6LO8Nb6dcUh6TItC455vu2kElga6PQvUmaFyCdykxY2nOSSL3yKgfbmFLRTUaGv4yV8", URL_SAFE, ) .unwrap(); - let auth = base64::decode_config("xS03Fi5ErfTNH_l9WHE9Ig", URL_SAFE).unwrap(); - let vapid_signature = VapidSignature { - auth_t: String::from("foo"), - auth_k: String::from("bar"), - }; + let http_ece = HttpEce::new(encoding, &p256dh, &auth, vapid_signature); + let content = "Hello, world!".as_bytes(); - let http_ece = HttpEce::new(ContentEncoding::AesGcm, &p256dh, &auth, Some(vapid_signature)); - - assert_eq!( - vec![ - ("Authorization", "WebPush foo".to_string()), - ("Crypto-Key", "dh=BBXpqeMbtt1iwSoYzs7uRL-QVSKTAuAPrunJoNyW2wMKeVBUyNFCqbkmpVTZOVbqWpwpr_-6TpJvk1qT8T-iOYs; p256ecdsa=bar".to_string()), - ("Encryption", "salt=YMcMuxqRkchXwy7vMwNl1Q".to_string())], - http_ece.generate_headers(&as_pubkey, &salt_bytes)) + http_ece.encrypt(content).unwrap() } #[test] - fn test_headers_without_vapid() { - let as_pubkey = base64::decode_config( - "BBXpqeMbtt1iwSoYzs7uRL-QVSKTAuAPrunJoNyW2wMKeVBUyNFCqbkmpVTZOVbqWpwpr_-6TpJvk1qT8T-iOYs=", - URL_SAFE, - ) - .unwrap(); - - let salt_bytes = base64::decode_config("YMcMuxqRkchXwy7vMwNl1Q==", URL_SAFE).unwrap(); - - let p256dh = base64::decode_config( - "BLMbF9ffKBiWQLCKvTHb6LO8Nb6dcUh6TItC455vu2kElga6PQvUmaFyCdykxY2nOSSL3yKgfbmFLRTUaGv4yV8", - URL_SAFE, - ) - .unwrap(); - - let auth = base64::decode_config("xS03Fi5ErfTNH_l9WHE9Ig", URL_SAFE).unwrap(); - - let http_ece = HttpEce::new(ContentEncoding::AesGcm, &p256dh, &auth, None); - - assert_eq!( - vec![ - ( - "Crypto-Key", - "dh=BBXpqeMbtt1iwSoYzs7uRL-QVSKTAuAPrunJoNyW2wMKeVBUyNFCqbkmpVTZOVbqWpwpr_-6TpJvk1qT8T-iOYs" - .to_string() - ), - ("Encryption", "salt=YMcMuxqRkchXwy7vMwNl1Q".to_string()) - ], - http_ece.generate_headers(&as_pubkey, &salt_bytes) - ) + fn test_aes128gcm_headers_no_vapid() { + let wp_payload = setup_payload(None, ContentEncoding::Aes128Gcm); + assert_eq!(wp_payload.crypto_headers.len(), 0); } #[test] - fn test_front_pad() { - // writes the padding count in the beginning, zeroes, content and again space for the encryption tag - let content = "naukio"; - let mut output = [0u8; 30]; - - front_pad(content.as_bytes(), &mut output); - - assert_eq!( - vec![0, 22, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 110, 97, 117, 107, 105, 111], - output - ); + fn test_aes128gcm_headers_vapid() { + let auth_re = Regex::new(r"vapid t=(?P[^,]*), k=(?P[^,]*)").unwrap(); + let vapid_signature = VapidSignature { + auth_t: String::from("foo"), + auth_k: String::from("bar").into_bytes(), + }; + let wp_payload = setup_payload(Some(vapid_signature), ContentEncoding::Aes128Gcm); + assert_eq!(wp_payload.crypto_headers.len(), 1); + let auth = wp_payload.crypto_headers[0].clone(); + assert_eq!(auth.0, "Authorization"); + assert!(auth_re.captures(&auth.1).is_some()); } } diff --git a/src/lib.rs b/src/lib.rs index 95a8f7b7..4bfb048c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,34 +1,43 @@ //! # Web Push //! //! A library for creating and sending push notifications to a web browser. For -//! content payload encryption it uses the [Encrypted Content-Encoding for HTTP, draft 3](https://datatracker.ietf.org/doc/draft-ietf-httpbis-encryption-encoding/03/?include_text=1). -//! The client is asynchronious and uses [Tokio](https://tokio.rs) with futures. +//! content payload encryption it uses [RFC8188](https://datatracker.ietf.org/doc/html/rfc8188). +//! The client is asynchronous and can run on any executor. An optional [`hyper`](https://crates.io/crates/hyper) based client is +//! available with the feature `hyper-client`. //! //! # Example //! //! ```no_run //! # use web_push::*; //! # use base64::URL_SAFE; +//! # use std::fs::File; //! # #[tokio::main] //! # async fn main() -> Result<(), Box> { //! let endpoint = "https://updates.push.services.mozilla.com/wpush/v1/..."; -//! let p256dh = base64::decode_config("key_from_browser_as_base64", URL_SAFE)?; -//! let auth = base64::decode_config("auth_from_browser_as_base64", URL_SAFE)?; +//! let p256dh = "key_from_browser_as_base64"; +//! let auth = "auth_from_browser_as_base64"; //! +//! //You would likely get this by deserializing a browser `pushSubscription` object. //! let subscription_info = SubscriptionInfo::new( //! endpoint, -//! "BLMbF9ffKBiWQLCKvTHb6LO8Nb6dcUh6TItC455vu2kElga6PQvUmaFyCdykxY2nOSSL3yKgfbmFLRTUaGv4yV8", -//! "xS03Fi5ErfTNH_l9WHE9Ig" +//! p256dh, +//! auth //! ); //! +//! //Read signing material for payload. +//! let file = File::open("private.pem").unwrap(); +//! let mut sig_builder = VapidSignatureBuilder::from_pem(file, &subscription_info)?.build()?; +//! +//! //Now add payload and encrypt. //! let mut builder = WebPushMessageBuilder::new(&subscription_info)?; //! let content = "Encrypted payload to be sent in the notification".as_bytes(); -//! builder.set_payload(ContentEncoding::AesGcm, content); +//! builder.set_payload(ContentEncoding::Aes128Gcm, content); +//! builder.set_vapid_signature(sig_builder); //! -//! let client = WebPushClient::new(); +//! let client = WebPushClient::new()?; //! -//! let response = client.send(builder.build()?).await?; -//! println!("Got response: {:?}", response); +//! //Finally, send the notification! +//! client.send(builder.build()?).await?; //! # Ok(()) //! # } //! ``` @@ -42,17 +51,24 @@ extern crate serde_json; #[macro_use] extern crate log; -mod client; +mod clients; mod error; mod http_ece; mod message; -mod services; mod vapid; -pub use crate::client::WebPushClient; +#[cfg(feature = "hyper-client")] +pub use crate::clients::hyper_client::WebPushClient; + +#[cfg(not(feature = "hyper-client"))] +pub use crate::clients::isahc_client::WebPushClient; + pub use crate::error::WebPushError; pub use crate::message::{SubscriptionInfo, SubscriptionKeys, WebPushMessage, WebPushMessageBuilder, WebPushPayload}; pub use crate::http_ece::ContentEncoding; pub use crate::vapid::{VapidSignature, VapidSignatureBuilder}; +pub use crate::vapid::builder::PartialVapidSignatureBuilder; + +pub use crate::clients::request_builder; diff --git a/src/message.rs b/src/message.rs index bebe4021..9b9fb042 100644 --- a/src/message.rs +++ b/src/message.rs @@ -1,19 +1,21 @@ use crate::error::WebPushError; use crate::http_ece::{ContentEncoding, HttpEce}; use crate::vapid::VapidSignature; -use hyper::Uri; +use http::uri::Uri; /// Encryption keys from the client. #[derive(Debug, Deserialize, Serialize)] pub struct SubscriptionKeys { - /// The public key + /// The public key. Base64 encoded. pub p256dh: String, - /// Authentication secret + /// Authentication secret. Base64 encoded. pub auth: String, } /// Client info for sending the notification. Maps the values from browser's -/// subscription info JSON data. +/// subscription info JSON data (AKA pushSubscription object). +/// +/// Client pushSubscription objects can be directly deserialized into this struct using serde. #[derive(Debug, Deserialize, Serialize)] pub struct SubscriptionInfo { /// The endpoint URI for sending the notification. @@ -39,12 +41,6 @@ impl SubscriptionInfo { } } -#[derive(Debug, Clone)] -pub enum WebPushService { - Firebase, - Autopush, -} - /// The push content payload, already in an encrypted form. #[derive(Debug, PartialEq)] pub struct WebPushPayload { @@ -59,9 +55,6 @@ pub struct WebPushPayload { /// Everything needed to send a push notification to the user. #[derive(Debug)] pub struct WebPushMessage { - /// When not using VAPID, certain browsers need a Firebase account key for - /// sending a notification. - pub gcm_key: Option, /// The endpoint URI where to send the payload. pub endpoint: Uri, /// Time to live, how long the message should wait in the server if user is @@ -69,9 +62,6 @@ pub struct WebPushMessage { pub ttl: u32, /// The encrypted request payload, if sending any data. pub payload: Option, - /// The service type where to connect. Firebase when not using VAPID with - /// Chrome-based browsers. Data is in JSON format instead of binary. - pub service: WebPushService, } struct WebPushPayloadBuilder<'a> { @@ -82,7 +72,6 @@ struct WebPushPayloadBuilder<'a> { /// The main class for creating a notification payload. pub struct WebPushMessageBuilder<'a> { subscription_info: &'a SubscriptionInfo, - gcm_key: Option<&'a str>, payload: Option>, ttl: u32, vapid_signature: Option, @@ -97,7 +86,6 @@ impl<'a> WebPushMessageBuilder<'a> { Ok(WebPushMessageBuilder { subscription_info, ttl: 2_419_200, - gcm_key: None, payload: None, vapid_signature: None, }) @@ -110,11 +98,6 @@ impl<'a> WebPushMessageBuilder<'a> { self.ttl = ttl; } - /// For Google's push service, one must provide an API key from Firebase console. - pub fn set_gcm_key(&mut self, gcm_key: &'a str) { - self.gcm_key = Some(gcm_key); - } - /// Add a VAPID signature to the request. To be generated with the /// [VapidSignatureBuilder](struct.VapidSignatureBuilder.html). pub fn set_vapid_signature(&mut self, vapid_signature: VapidSignature) { @@ -123,24 +106,20 @@ impl<'a> WebPushMessageBuilder<'a> { /// If set, the client will get content in the notification. Has a maximum size of /// 3800 characters. + /// + /// Currently, Aes128Gcm is the recommended and only encoding standard implemented. pub fn set_payload(&mut self, encoding: ContentEncoding, content: &'a [u8]) { self.payload = Some(WebPushPayloadBuilder { content, encoding }); } - /// Builds and if set, encrypts the payload. Any errors will be `Undefined`, meaning + /// Builds and if set, encrypts the payload. Any errors due to bad encryption will be + /// [`WebPushError::Unspecified`], meaning /// something was wrong in the given public key or authentication. + /// You can further debug these issues by checking the API responses visible with + /// `log::trace` level. pub fn build(self) -> Result { let endpoint: Uri = self.subscription_info.endpoint.parse()?; - let service = match self.vapid_signature { - Some(_) => WebPushService::Autopush, - _ => match endpoint.host() { - Some("android.googleapis.com") => WebPushService::Firebase, - Some("fcm.googleapis.com") => WebPushService::Firebase, - _ => WebPushService::Autopush, - }, - }; - if let Some(payload) = self.payload { let p256dh = base64::decode_config(&self.subscription_info.keys.p256dh, base64::URL_SAFE)?; let auth = base64::decode_config(&self.subscription_info.keys.auth, base64::URL_SAFE)?; @@ -148,19 +127,15 @@ impl<'a> WebPushMessageBuilder<'a> { let http_ece = HttpEce::new(payload.encoding, &p256dh, &auth, self.vapid_signature); Ok(WebPushMessage { - gcm_key: self.gcm_key.map(|k| k.to_string()), endpoint, ttl: self.ttl, payload: Some(http_ece.encrypt(payload.content)?), - service, }) } else { Ok(WebPushMessage { - gcm_key: self.gcm_key.map(|k| k.to_string()), endpoint, ttl: self.ttl, payload: None, - service, }) } } diff --git a/src/services/firebase.rs b/src/services/firebase.rs deleted file mode 100644 index 478d3952..00000000 --- a/src/services/firebase.rs +++ /dev/null @@ -1,369 +0,0 @@ -use crate::{error::WebPushError, message::WebPushMessage}; -use http::header::{AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE}; -use hyper::{Body, Request, StatusCode}; - -#[derive(Deserialize, Serialize, Debug)] -pub enum GcmError { - MissingRegistration, - InvalidRegistration, - NotRegistered, - InvalidPackageName, - MismatchSenderId, - InvalidParameters, - MessageTooBig, - InvalidDataKey, - InvalidTtl, - Unavailable, - InternalServerError, - DeviceMessageRateExceeded, - TopicsMessageRateExceeded, - InvalidApnsCredential, -} - -impl<'a> From<&'a GcmError> for WebPushError { - fn from(e: &GcmError) -> WebPushError { - match e { - &GcmError::MissingRegistration => WebPushError::EndpointNotFound, - &GcmError::InvalidRegistration => WebPushError::EndpointNotValid, - &GcmError::NotRegistered => WebPushError::EndpointNotValid, - &GcmError::InvalidPackageName => WebPushError::InvalidPackageName, - &GcmError::MessageTooBig => WebPushError::PayloadTooLarge, - &GcmError::InvalidTtl => WebPushError::InvalidTtl, - &GcmError::Unavailable => WebPushError::ServerError(None), - &GcmError::InternalServerError => WebPushError::ServerError(None), - e => WebPushError::Other(format!("{:?}", e)), - } - } -} - -#[derive(Deserialize, Serialize, Debug)] -pub struct GcmResponse { - pub message_id: Option, - pub error: Option, - pub multicast_id: Option, - pub success: Option, - pub failure: Option, - pub canonical_ids: Option, - pub results: Option>, -} - -#[derive(Deserialize, Serialize, Debug)] -pub struct MessageResult { - pub message_id: Option, - pub registration_id: Option, - pub error: Option, -} - -#[derive(Deserialize, Serialize)] -struct GcmData { - registration_ids: Vec, - raw_data: Option, -} - -pub fn build_request(message: WebPushMessage) -> Request { - let uri = match message.endpoint.host() { - Some("fcm.googleapis.com") => "https://fcm.googleapis.com/fcm/send", - _ => "https://android.googleapis.com/gcm/send", - }; - - let mut builder = Request::builder().method("POST").uri(uri); - - if let Some(ref gcm_key) = message.gcm_key { - builder = builder.header(AUTHORIZATION, format!("key={}", gcm_key).as_bytes()); - } - - let mut registration_ids = Vec::with_capacity(1); - - if let Some(token) = message.endpoint.path().split('/').last() { - registration_ids.push(token.to_string()); - } - - let raw_data = match message.payload { - Some(payload) => { - for (k, v) in payload.crypto_headers.into_iter() { - let v: &str = v.as_ref(); - builder = builder.header(k, v); - } - - Some(base64::encode(&payload.content)) - } - None => None, - }; - - let gcm_data = GcmData { - registration_ids, - raw_data, - }; - - let json_payload = serde_json::to_string(&gcm_data).unwrap(); - - builder - .header(CONTENT_TYPE, "application/json") - .header(CONTENT_LENGTH, format!("{}", json_payload.len() as u64).as_bytes()) - .body(json_payload.into()) - .unwrap() -} - -pub fn parse_response(response_status: StatusCode, body: Vec) -> Result<(), WebPushError> { - match response_status { - StatusCode::OK => { - let body_str = String::from_utf8(body)?; - let gcm_response: GcmResponse = serde_json::from_str(&body_str)?; - - if let Some(0) = gcm_response.failure { - Ok(()) - } else { - match gcm_response.results { - Some(results) => match results.first() { - Some(result) => match result.error { - Some(ref error) => Err(WebPushError::from(error)), - _ => Err(WebPushError::Other(String::from("UnknownError"))), - }, - _ => Err(WebPushError::Other(String::from("UnknownError"))), - }, - _ => Err(WebPushError::Other(String::from("UnknownError"))), - } - } - } - StatusCode::UNAUTHORIZED => Err(WebPushError::Unauthorized), - StatusCode::BAD_REQUEST => { - let body_str = String::from_utf8(body)?; - let gcm_response: GcmResponse = serde_json::from_str(&body_str)?; - - match gcm_response.error { - Some(e) => Err(WebPushError::from(&e)), - _ => Err(WebPushError::BadRequest(None)), - } - } - status if status.is_server_error() => Err(WebPushError::ServerError(None)), - e => Err(WebPushError::Other(format!("{:?}", e))), - } -} - -#[cfg(test)] -mod tests { - use crate::error::WebPushError; - use crate::http_ece::ContentEncoding; - use crate::message::{SubscriptionInfo, WebPushMessageBuilder}; - use crate::services::firebase::*; - use hyper::StatusCode; - use hyper::Uri; - - #[test] - fn builds_a_correct_request_with_empty_payload() { - let info = SubscriptionInfo::new( - "https://android.googleapis.com/gcm/send/device_token_2", - "BLMbF9ffKBiWQLCKvTHb6LO8Nb6dcUh6TItC455vu2kElga6PQvUmaFyCdykxY2nOSSL3yKgfbmFLRTUaGv4yV8", - "xS03Fi5ErfTNH_l9WHE9Ig", - ); - - let mut builder = WebPushMessageBuilder::new(&info).unwrap(); - - builder.set_gcm_key("test_key"); - builder.set_ttl(420); - builder.set_payload(ContentEncoding::AesGcm, "test".as_bytes()); - - let request = build_request(builder.build().unwrap()); - - let authorization = request.headers().get("Authorization").unwrap().to_str().unwrap(); - - let expected_uri: Uri = "https://android.googleapis.com/gcm/send".parse().unwrap(); - - assert_eq!("key=test_key", authorization); - assert_eq!(expected_uri.host(), request.uri().host()); - assert_eq!(expected_uri.path(), request.uri().path()); - } - - #[test] - fn builds_a_correct_request_with_a_payload() { - let info = SubscriptionInfo::new( - "https://fcm.googleapis.com/gcm/send/device_token_2", - "BLMbF9ffKBiWQLCKvTHb6LO8Nb6dcUh6TItC455vu2kElga6PQvUmaFyCdykxY2nOSSL3yKgfbmFLRTUaGv4yV8", - "xS03Fi5ErfTNH_l9WHE9Ig", - ); - - let mut builder = WebPushMessageBuilder::new(&info).unwrap(); - - builder.set_gcm_key("test_key"); - builder.set_ttl(420); - builder.set_payload(ContentEncoding::AesGcm, "test".as_bytes()); - - let request = build_request(builder.build().unwrap()); - - let authorization = request.headers().get("Authorization").unwrap().to_str().unwrap(); - - let expected_uri: Uri = "https://fcm.googleapis.com/fcm/send".parse().unwrap(); - let length = request.headers().get("Content-Length").unwrap(); - - assert_eq!("key=test_key", authorization); - assert_eq!(expected_uri.host(), request.uri().host()); - assert_eq!(expected_uri.path(), request.uri().path()); - assert_eq!("4149", length); - } - - #[test] - fn parses_a_successful_response_correctly() { - let response = r#" - { - "message_id": 12, - "multicast_id": 33, - "success": 1, - "failure": 0 - } - "#; - assert_eq!(Ok(()), parse_response(StatusCode::OK, response.as_bytes().to_vec())) - } - - #[test] - fn parses_a_missing_registration_response_correctly() { - let response = r#" - { - "message_id": 12, - "multicast_id": 33, - "success": 0, - "failure": 1, - "results": [{"error":"MissingRegistration"}] - } - "#; - assert_eq!( - Err(WebPushError::EndpointNotFound), - parse_response(StatusCode::OK, response.as_bytes().to_vec()) - ) - } - - #[test] - fn parses_a_invalid_registration_response_correctly() { - let response = r#" - { - "message_id": 12, - "multicast_id": 33, - "success": 0, - "failure": 1, - "results": [{"error":"InvalidRegistration"}] - } - "#; - assert_eq!( - Err(WebPushError::EndpointNotValid), - parse_response(StatusCode::OK, response.as_bytes().to_vec()) - ) - } - - #[test] - fn parses_a_not_registered_response_correctly() { - let response = r#" - { - "message_id": 12, - "multicast_id": 33, - "success": 0, - "failure": 1, - "results": [{"error":"NotRegistered"}] - } - "#; - assert_eq!( - Err(WebPushError::EndpointNotValid), - parse_response(StatusCode::OK, response.as_bytes().to_vec()) - ) - } - - #[test] - fn parses_an_invalid_package_name_response_correctly() { - let response = r#" - { - "message_id": 12, - "multicast_id": 33, - "success": 0, - "failure": 1, - "results": [{"error":"InvalidPackageName"}] - } - "#; - assert_eq!( - Err(WebPushError::InvalidPackageName), - parse_response(StatusCode::OK, response.as_bytes().to_vec()) - ) - } - - #[test] - fn parses_a_message_too_big_response_correctly() { - let response = r#" - { - "message_id": 12, - "multicast_id": 33, - "success": 0, - "failure": 1, - "results": [{"error":"MessageTooBig"}] - } - "#; - assert_eq!( - Err(WebPushError::PayloadTooLarge), - parse_response(StatusCode::OK, response.as_bytes().to_vec()) - ) - } - - #[test] - fn parses_an_invalid_data_key_response_correctly() { - let response = r#" - { - "message_id": 12, - "multicast_id": 33, - "success": 0, - "failure": 1, - "results": [{"error":"InvalidDataKey"}] - } - "#; - assert_eq!( - Err(WebPushError::Other(String::from("InvalidDataKey"))), - parse_response(StatusCode::OK, response.as_bytes().to_vec()) - ) - } - - #[test] - fn parses_an_invalid_ttl_response_correctly() { - let response = r#" - { - "message_id": 12, - "multicast_id": 33, - "success": 0, - "failure": 1, - "results": [{"error":"InvalidTtl"}] - } - "#; - assert_eq!( - Err(WebPushError::InvalidTtl), - parse_response(StatusCode::OK, response.as_bytes().to_vec()) - ) - } - - #[test] - fn parses_an_unavailable_response_correctly() { - let response = r#" - { - "message_id": 12, - "multicast_id": 33, - "success": 0, - "failure": 1, - "results": [{"error":"Unavailable"}] - } - "#; - assert_eq!( - Err(WebPushError::ServerError(None)), - parse_response(StatusCode::OK, response.as_bytes().to_vec()) - ) - } - - #[test] - fn parses_an_internal_server_error_response_correctly() { - let response = r#" - { - "message_id": 12, - "multicast_id": 33, - "success": 0, - "failure": 1, - "results": [{"error":"InternalServerError"}] - } - "#; - assert_eq!( - Err(WebPushError::ServerError(None)), - parse_response(StatusCode::OK, response.as_bytes().to_vec()) - ) - } -} diff --git a/src/services/mod.rs b/src/services/mod.rs deleted file mode 100644 index 6fd649d0..00000000 --- a/src/services/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod autopush; -pub mod firebase; diff --git a/src/vapid/builder.rs b/src/vapid/builder.rs index 199eb2d7..866aeb74 100644 --- a/src/vapid/builder.rs +++ b/src/vapid/builder.rs @@ -1,7 +1,7 @@ use crate::error::WebPushError; use crate::message::SubscriptionInfo; use crate::vapid::{VapidKey, VapidSignature, VapidSigner}; -use hyper::Uri; +use http::uri::Uri; use openssl::ec::EcKey; use openssl::pkey::Private; use serde_json::Value; @@ -9,11 +9,10 @@ use std::collections::BTreeMap; use std::io::Read; /// A VAPID signature builder for generating an optional signature to the -/// request. With a given signature, one can pass the registration to Google's -/// FCM service. And prevent unauthorized notifications to be sent to the client. +/// request. This encryption is required for payloads in all current and future browsers. /// /// To communicate with the site, one needs to generate a private key to keep in -/// the server and derive a public key from the generated private key to the +/// the server and derive a public key from the generated private key for the /// client. /// /// Private key generation: @@ -41,6 +40,8 @@ use std::io::Read; /// openssl ec -in private.pem -pubout -outform DER|tail -c 65|base64|tr '/+' '_-'|tr -d '\n' /// ``` /// +/// The above commands can be done in code using a library such as openssl if you prefer. +/// /// To create a VAPID signature: /// /// ```no_run @@ -48,6 +49,7 @@ use std::io::Read; /// # use web_push::*; /// # use std::fs::File; /// # fn main () { +/// //You would get this as a `pushSubscription` object from the client. They need your public key to get that object. /// let subscription_info = SubscriptionInfo { /// keys: SubscriptionKeys { /// p256dh: String::from("something"), @@ -59,6 +61,8 @@ use std::io::Read; /// let file = File::open("private.pem").unwrap(); /// /// let mut sig_builder = VapidSignatureBuilder::from_pem(file, &subscription_info).unwrap(); +/// +/// //These fields are optional, and likely unneeded for most uses. /// sig_builder.add_claim("sub", "mailto:test@example.com"); /// sig_builder.add_claim("foo", "bar"); /// sig_builder.add_claim("omg", 123); @@ -75,6 +79,11 @@ pub struct VapidSignatureBuilder<'a> { impl<'a> VapidSignatureBuilder<'a> { /// Creates a new builder from a PEM formatted private key. + /// + /// # Details + /// + /// This should be the raw private key PEM, including the -----BEGIN EC PRIVATE KEY----- header. + /// If you have a public and private key in the same PEM, the function will still work. pub fn from_pem( mut pk_pem: R, subscription_info: &'a SubscriptionInfo, @@ -82,7 +91,27 @@ impl<'a> VapidSignatureBuilder<'a> { let mut pem_key: Vec = Vec::new(); pk_pem.read_to_end(&mut pem_key)?; - Ok(Self::from_ec(EcKey::private_key_from_pem(&pem_key)?, subscription_info)) + let pr_key = EcKey::private_key_from_pem(&pem_key)?; + + Ok(Self::from_ec(pr_key, subscription_info)) + } + + /// Creates a new builder from a PEM formatted private key. This function doesn't take a subscription, + /// allowing the reuse of one builder for multiple messages by cloning the resulting builder. + /// + /// # Details + /// + /// This should be the raw private key PEM, including the -----BEGIN EC PRIVATE KEY----- header. + /// If you have a public and private key in the same PEM, the function will still work. + pub fn from_pem_no_sub(mut pk_pem: R) -> Result { + let mut pem_key: Vec = Vec::new(); + pk_pem.read_to_end(&mut pem_key)?; + + let pr_key = EcKey::private_key_from_pem(&pem_key)?; + + Ok(PartialVapidSignatureBuilder { + key: VapidKey::new(pr_key), + }) } /// Creates a new builder from a DER formatted private key. @@ -96,6 +125,17 @@ impl<'a> VapidSignatureBuilder<'a> { Ok(Self::from_ec(EcKey::private_key_from_der(&der_key)?, subscription_info)) } + /// Creates a new builder from a DER formatted private key. This function doesn't take a subscription, + /// allowing the reuse of one builder for multiple messages by cloning the resulting builder. + pub fn from_der_no_sub(mut pk_der: R) -> Result { + let mut der_key: Vec = Vec::new(); + pk_der.read_to_end(&mut der_key)?; + + Ok(PartialVapidSignatureBuilder { + key: VapidKey::new(EcKey::private_key_from_der(&der_key)?), + }) + } + /// Add a claim to the signature. Claims `aud` and `exp` are automatically /// added to the signature. Add them manually to override the default /// values. @@ -126,11 +166,57 @@ impl<'a> VapidSignatureBuilder<'a> { } } +/// A [`VapidSignatureBuilder`] without VAPID subscription info. +/// +/// # Example +/// +/// ```no_run +/// use web_push::{VapidSignatureBuilder, SubscriptionInfo}; +/// +/// let builder = VapidSignatureBuilder::from_pem_no_sub("Some PEM".as_bytes()).unwrap(); +/// +/// //Clone builder for each use of the same private key +/// { +/// //Pretend this changes for each connection +/// let subscription_info = SubscriptionInfo::new( +/// "https://updates.push.services.mozilla.com/wpush/v1/...", +/// "BLMbF9ffKBiWQLCKvTHb6LO8Nb6dcUh6TItC455vu2kElga6PQvUmaFyCdykxY2nOSSL3yKgfbmFLRTUaGv4yV8", +/// "xS03Fi5ErfTNH_l9WHE9Ig" +/// ); +/// +/// let builder = builder.clone(); +/// let sig = builder.add_sub_info(&subscription_info).build(); +/// //Sign message ect. +/// } +/// +/// ``` +#[derive(Clone)] +pub struct PartialVapidSignatureBuilder { + key: VapidKey, +} + +impl<'a> PartialVapidSignatureBuilder { + /// Adds the VAPID subscription info for a particular client. + pub fn add_sub_info(self, subscription_info: &'a SubscriptionInfo) -> VapidSignatureBuilder { + VapidSignatureBuilder { + key: self.key, + claims: BTreeMap::new(), + subscription_info, + } + } + + /// Gets the public key bytes derived from the private key used for this VAPID signature. + /// + /// Base64 encode these bytes to get the key to send to the client. + pub fn get_public_key(&self) -> Vec { + self.key.public_key() + } +} + #[cfg(test)] mod tests { use crate::message::SubscriptionInfo; use crate::vapid::VapidSignatureBuilder; - use serde_json; use std::fs::File; lazy_static! { @@ -158,7 +244,7 @@ mod tests { assert_eq!( "BMo1HqKF6skMZYykrte9duqYwBD08mDQKTunRkJdD3sTJ9E-yyN6sJlPWTpKNhp-y2KeS6oANHF-q3w37bClb7U", - &signature.auth_k + base64::encode_config(&signature.auth_k, base64::URL_SAFE_NO_PAD) ); assert!(!signature.auth_t.is_empty()); @@ -171,7 +257,7 @@ mod tests { assert_eq!( "BMo1HqKF6skMZYykrte9duqYwBD08mDQKTunRkJdD3sTJ9E-yyN6sJlPWTpKNhp-y2KeS6oANHF-q3w37bClb7U", - &signature.auth_k + base64::encode_config(&signature.auth_k, base64::URL_SAFE_NO_PAD) ); assert!(!signature.auth_t.is_empty()); diff --git a/src/vapid/key.rs b/src/vapid/key.rs index a1e7bf94..778d6f64 100644 --- a/src/vapid/key.rs +++ b/src/vapid/key.rs @@ -3,6 +3,7 @@ use openssl::ec::{EcGroup, EcKey, PointConversionForm}; use openssl::nid::Nid; use openssl::pkey::Private; +#[derive(Clone)] pub struct VapidKey(pub EcKey); lazy_static! { @@ -14,6 +15,7 @@ impl VapidKey { VapidKey(ec_key) } + /// Gets the public key bytes derived from this private key. pub fn public_key(&self) -> Vec { let mut ctx = BigNumContext::new().unwrap(); let key = self.0.public_key(); diff --git a/src/vapid/mod.rs b/src/vapid/mod.rs index 7c0c120f..e216ff85 100644 --- a/src/vapid/mod.rs +++ b/src/vapid/mod.rs @@ -1,4 +1,4 @@ -mod builder; +pub mod builder; mod key; mod signer; diff --git a/src/vapid/signer.rs b/src/vapid/signer.rs index eb9b695f..598411ff 100644 --- a/src/vapid/signer.rs +++ b/src/vapid/signer.rs @@ -1,18 +1,15 @@ use crate::{error::WebPushError, vapid::VapidKey}; use base64::{self, URL_SAFE_NO_PAD}; -use hyper::Uri; +use http::uri::Uri; use openssl::{hash::MessageDigest, pkey::PKey, sign::Signer as SslSigner}; use serde_json::{Number, Value}; use std::collections::BTreeMap; use time::{self, OffsetDateTime}; lazy_static! { + /// This is the header of all JWTs. static ref JWT_HEADERS: String = base64::encode_config( - &serde_json::to_string(&json!({ - "typ": "JWT", - "alg": "ES256" - })) - .unwrap(), + &serde_json::to_string(&json!({"typ": "JWT","alg": "ES256"})).unwrap(), URL_SAFE_NO_PAD ); } @@ -21,16 +18,10 @@ lazy_static! { /// [VapidSignatureBuilder](struct.VapidSignatureBuilder.html). #[derive(Debug)] pub struct VapidSignature { - /// The signature + /// The signed JWT, base64 encoded pub auth_t: String, - /// The public key - pub auth_k: String, -} - -impl ToString for VapidSignature { - fn to_string(&self) -> String { - format!("WebPush {}", self.auth_t) - } + /// The public key bytes + pub auth_k: Vec, } pub struct VapidSigner {} @@ -55,14 +46,15 @@ impl VapidSigner { claims.insert("exp", Value::Number(number)); } + //Generate first half of JWT let signing_input = format!( "{}.{}", *JWT_HEADERS, base64::encode_config(&serde_json::to_string(&claims)?, URL_SAFE_NO_PAD) ); - let public_key = key.public_key(); - let auth_k = base64::encode_config(&public_key, URL_SAFE_NO_PAD); + let auth_k = key.public_key(); + let pkey = PKey::from_ec_key(key.0)?; let mut signer = SslSigner::new(MessageDigest::sha256(), &pkey)?; @@ -90,8 +82,6 @@ impl VapidSigner { sigval.extend(r_val); sigval.extend(s_val); - trace!("Public key: {}", auth_k); - let auth_t = format!("{}.{}", signing_input, base64::encode_config(&sigval, URL_SAFE_NO_PAD)); Ok(VapidSignature { auth_t, auth_k }) @@ -99,18 +89,4 @@ impl VapidSigner { } #[cfg(test)] -mod tests { - use crate::vapid::VapidSignature; - - #[test] - fn test_vapid_signature_aesgcm_format() { - let vapid_signature = &VapidSignature { - auth_t: String::from("foo"), - auth_k: String::from("bar"), - }; - - let header_value: String = vapid_signature.to_string(); - - assert_eq!("WebPush foo", &header_value); - } -} +mod tests {}