From 4cac72a2ca2e6c8414f9ac49fcf41609ab35ac28 Mon Sep 17 00:00:00 2001 From: Andrew Ealovega Date: Tue, 10 Aug 2021 14:02:26 -0400 Subject: [PATCH] Implement aes128gcm, remove legacy code, add exactor agnostic client, update docs, and more. (#30) * WIP : implementing ECE with ECE crate. Missing tests. Not compiling yet * Restaured front padding for both encryption methods * Removed structures that are no longer in use * Removed tests that are now handled by the rust_ece crate * Removed obsolete comment * Updated readme to include new standard * Removed tests that are covered in the rust-ece crate prepared tests for headers that are left to do * Implemented some of the headers tests * Fixed numbers with new double-padding (some small padding is added in the rust-ece crate) * Implemented last tests for aes128gcm * Cargo fmt * WIP : added option for testing in the example file * Cargo fmt * Remove padding from aes128Gcm. This allows for aes128Gcm to fully operate in all tested browsers, including edge. * Remove unneeded base64URL encoding. * Remove uses of aesgcm. * Fix tests, store VAPID keys as bytes, and add reusable VAPID builder. This commit will fix the base64 encoding issues by directly reading the PEM key as bytes instead of immediately encoding, allowing consumers to choose to base64 encode or not. * Fix doc-tests. * Remove FCM specific code. Use the autopush implementation for all connections. This is possible as web push has been standardised, leaving this legacy code redundant. The FCM crate should cover all uses of FCM by this crates consumers. * Use isahc as the default client backend. Add `hyper-client` feature to optionally use the old hyper client. Isach is runtime independent, allowing this crate to be used in non Tokio environments. The hyper client remains as a potentially faster and more maintained option. Isach is the default as I feel the crate should work out of the box on any async executor. * Update docs and README.md. Expose request_builder now that it's generic and documented. * Bump to v0.8 * Add method to get the public key from VAPID signature. * Add encryption test and change fake subs into real subs. * Fix final doc test. * Add migration info to README.md. Co-authored-by: John Tiesselune --- Cargo.toml | 17 +- README.md | 109 ++++-- examples/simple_send.rs | 27 +- src/client.rs | 91 ----- src/clients/hyper_client.rs | 84 ++++ src/clients/isahc_client.rs | 83 ++++ src/clients/mod.rs | 13 + .../request_builder.rs} | 83 ++-- src/error.rs | 8 + src/http_ece.rs | 347 ++++------------ src/lib.rs | 42 +- src/message.rs | 49 +-- src/services/firebase.rs | 369 ------------------ src/services/mod.rs | 2 - src/vapid/builder.rs | 102 ++++- src/vapid/key.rs | 2 + src/vapid/mod.rs | 2 +- src/vapid/signer.rs | 44 +-- 18 files changed, 569 insertions(+), 905 deletions(-) delete mode 100644 src/client.rs create mode 100644 src/clients/hyper_client.rs create mode 100644 src/clients/isahc_client.rs create mode 100644 src/clients/mod.rs rename src/{services/autopush.rs => clients/request_builder.rs} (62%) delete mode 100644 src/services/firebase.rs delete mode 100644 src/services/mod.rs 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 {}