From 994b683f0ba976cd8d5ff5de46fc6563b3ae7b2b Mon Sep 17 00:00:00 2001 From: Andrew Ealovega Date: Sun, 2 Apr 2023 22:26:34 -0400 Subject: [PATCH] Add aesgcm support back, and fix some firefox bugs (#44) * Add back support for the legacy aesgcm cypher. It appears some services still rely on it, so here it is. Unlike the old pre 0.8.0 version, we now rely on Mozilla's ECE crate, which is much better tested. * Add aesgcm tests. * update example. * Update docs. * Always add a dummy JWT subs filed, since without it FireFox gives 401 errors. This isn't actually required by the standard, so this quite surprising to me. * Better clarify that aesgcm should not be used unless truly needed. * Bump to 0.9.5 * Format Rust code using rustfmt --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Cargo.toml | 4 +- README.md | 68 +++++++++----------- examples/simple_send.rs | 5 +- src/clients/request_builder.rs | 2 +- src/http_ece.rs | 113 +++++++++++++++++++++++++++++---- src/message.rs | 10 +-- src/vapid/signer.rs | 7 ++ 7 files changed, 146 insertions(+), 63 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9c5b1fef..24005b44 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.9.4" +version = "0.9.5" authors = ["Julius de Bruijn ", "Andrew Ealovega "] license = "Apache-2.0" homepage = "https://github.com/pimeys/rust-web-push" @@ -26,7 +26,7 @@ serde = "^1.0" serde_json = "^1.0" serde_derive = "^1.0" jwt-simple = "0.11.2" -ece = "^2.1" +ece = "^2.2" pem = "1.1.0" sec1_decode = "^0.1.0" base64 = "^0.13" diff --git a/README.md b/README.md index 2069bd9f..7679271e 100644 --- a/README.md +++ b/README.md @@ -5,57 +5,25 @@ Rust 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) +This crate implements the server half of the web push API, in Rust! -Web push notification sender. +For more background on the web push framework itself, please +reference [this excellent document.](https://web.dev/notifications/) ## Requirements Clients require an async executor. System Openssl is needed for compilation. -## Migration to greater than v0.7 +## Migration notes -- 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. - -- A new error variant `WebPushError::InvalidClaims` has been added. This may break exhaustive matches. - -## 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: - -``` json -{ - "endpoint": "https://updates.push.services.mozilla.com/wpush/v1/TOKEN", - "keys": { - "auth": "####secret####", - "p256dh": "####public_key####" - } -} -``` - -Google has -[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!"`. +This library is still in active development, and will have breaking changes in accordance with semver. Please view the +GitHub release notes for detailed notes. Example -------- ```rust use web_push::*; -use base64::URL_SAFE; use std::fs::File; #[tokio::main] @@ -64,7 +32,7 @@ async fn main() -> Result<(), Box 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. + //You would likely get this by deserializing a browser `pushSubscription` object via serde. let subscription_info = SubscriptionInfo::new( endpoint, p256dh, @@ -110,6 +78,28 @@ openssl ec -in private_key.pem -pubout -outform DER|tail -c 65|base64|tr '/+' '_ 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. +## Using the example program + +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 +{ + "endpoint": "https://updates.push.services.mozilla.com/wpush/v1/TOKEN", + "keys": { + "auth": "####secret####", + "p256dh": "####public_key####" + } +} +``` + +Google has +[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!"`. + Overview -------- diff --git a/examples/simple_send.rs b/examples/simple_send.rs index 3ea52a9c..d37a5d27 100644 --- a/examples/simple_send.rs +++ b/examples/simple_send.rs @@ -23,7 +23,7 @@ async fn main() -> Result<(), Box ap.refer(&mut encoding).add_option( &["-e", "--encoding"], StoreOption, - "Content Encoding Scheme : currently only accepts 'aes128gcm'. Defaults to 'aes128gcm'. Reserved for future standards.", + "Content Encoding Scheme : 'aes128gcm' or 'aesgcm'", ); ap.refer(&mut subscription_info_file).add_option( @@ -47,8 +47,9 @@ async fn main() -> Result<(), Box let ece_scheme = match encoding.as_deref() { Some("aes128gcm") => ContentEncoding::Aes128Gcm, + Some("aesgcm") => ContentEncoding::AesGcm, None => ContentEncoding::Aes128Gcm, - Some(_) => panic!("Content encoding can only be 'aes128gcm'"), + Some(_) => panic!("Content encoding can only be 'aes128gcm' or 'aesgcm'"), }; let subscription_info: SubscriptionInfo = serde_json::from_str(&contents).unwrap(); diff --git a/src/clients/request_builder.rs b/src/clients/request_builder.rs index 395fd0fb..02b88274 100644 --- a/src/clients/request_builder.rs +++ b/src/clients/request_builder.rs @@ -51,7 +51,7 @@ where if let Some(payload) = message.payload { builder = builder - .header(CONTENT_ENCODING, payload.content_encoding) + .header(CONTENT_ENCODING, payload.content_encoding.to_str()) .header(CONTENT_LENGTH, format!("{}", payload.content.len() as u64).as_bytes()) .header(CONTENT_TYPE, "application/octet-stream"); diff --git a/src/http_ece.rs b/src/http_ece.rs index 54c2fe4b..465bde19 100644 --- a/src/http_ece.rs +++ b/src/http_ece.rs @@ -1,3 +1,5 @@ +//! Payload encryption algorithm + use ece::encrypt; use crate::error::WebPushError; @@ -5,9 +7,23 @@ use crate::message::WebPushPayload; use crate::vapid::VapidSignature; /// Content encoding profiles. +#[derive(Debug, PartialEq, Copy, Clone, Default)] pub enum ContentEncoding { //Make sure this enum remains exhaustive as that allows for easier migrations to new versions. + #[default] Aes128Gcm, + /// Note: this is an older version of ECE, and should not be used unless you know for sure it is required. In all other cases, use aes128gcm. + AesGcm, +} + +impl ContentEncoding { + /// Gets the associated string for this content encoding, as would be used in the content-encoding header. + pub fn to_str(&self) -> &'static str { + match &self { + ContentEncoding::Aes128Gcm => "aes128gcm", + ContentEncoding::AesGcm => "aesgcm", + } + } } /// Struct for handling payload encryption. @@ -52,29 +68,61 @@ impl<'a> HttpEce<'a> { let mut headers = Vec::new(); - //VAPID uses a special Authorisation header, which contains a ecdhsa key and a jwt. - 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) - ), - )); - } + self.add_vapid_headers(&mut headers); match result { Ok(data) => Ok(WebPushPayload { content: data, crypto_headers: headers, - content_encoding: "aes128gcm", + content_encoding: self.encoding, }), _ => Err(WebPushError::InvalidCryptoKeys), } } + ContentEncoding::AesGcm => { + let result = self.aesgcm_encrypt(content); + + let data = result.map_err(|_| WebPushError::InvalidCryptoKeys)?; + + // Get headers exclusive to the aesgcm scheme (Crypto-Key ect.) + let mut headers = data.headers(self.vapid_signature.as_ref().map(|v| v.auth_k.as_slice())); + + self.add_vapid_headers(&mut headers); + + // ECE library base64 encodes content in aesgcm, but not aes128gcm, so decode base64 here to match the 128 API + let data = base64::decode_config(data.body(), base64::URL_SAFE_NO_PAD) + .expect("ECE library should always base64 encode"); + + Ok(WebPushPayload { + content: data, + crypto_headers: headers, + content_encoding: self.encoding, + }) + } } } + + /// Adds VAPID authorisation header to headers, if VAPID is being used. + fn add_vapid_headers(&self, headers: &mut Vec<(&str, String)>) { + //VAPID uses a special Authorisation header, which contains a ecdhsa key and a jwt. + 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) + ), + )); + } + } + + /// Encrypts the content using the aesgcm encoding. + /// + /// This is extracted into a function for testing. + fn aesgcm_encrypt(&self, content: &[u8]) -> ece::Result { + ece::legacy::encrypt_aesgcm(self.peer_public_key, self.peer_secret, content) + } } #[cfg(test)] @@ -122,6 +170,26 @@ mod tests { ) } + /// Tests that the content encryption is properly reversible while using aesgcm. + #[test] + fn test_payload_encrypts() { + 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::AesGcm, p_key, &auth, None); + let plaintext = "Hello world!"; + let ciphertext = http_ece.aesgcm_encrypt(plaintext.as_bytes()).unwrap(); + + assert_ne!(plaintext, ciphertext.body()); + + assert_eq!( + String::from_utf8(ece::legacy::decrypt_aesgcm(&key.raw_components().unwrap(), &auth, &ciphertext).unwrap()) + .unwrap(), + plaintext + ) + } + fn setup_payload(vapid_signature: Option, encoding: ContentEncoding) -> WebPushPayload { let p256dh = base64::decode_config( "BLMbF9ffKBiWQLCKvTHb6LO8Nb6dcUh6TItC455vu2kElga6PQvUmaFyCdykxY2nOSSL3yKgfbmFLRTUaGv4yV8", @@ -142,6 +210,12 @@ mod tests { assert_eq!(wp_payload.crypto_headers.len(), 0); } + #[test] + fn test_aesgcm_headers_no_vapid() { + let wp_payload = setup_payload(None, ContentEncoding::AesGcm); + assert_eq!(wp_payload.crypto_headers.len(), 2); + } + #[test] fn test_aes128gcm_headers_vapid() { let auth_re = Regex::new(r"vapid t=(?P[^,]*), k=(?P[^,]*)").unwrap(); @@ -155,4 +229,19 @@ mod tests { assert_eq!(auth.0, "Authorization"); assert!(auth_re.captures(&auth.1).is_some()); } + + #[test] + fn test_aesgcm_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::AesGcm); + // Should have Authorization, Crypto-key, and Encryption + assert_eq!(wp_payload.crypto_headers.len(), 3); + let auth = wp_payload.crypto_headers[2].clone(); + assert_eq!(auth.0, "Authorization"); + assert!(auth_re.captures(&auth.1).is_some()); + } } diff --git a/src/message.rs b/src/message.rs index 836dac1d..8eb3d516 100644 --- a/src/message.rs +++ b/src/message.rs @@ -51,7 +51,7 @@ pub struct WebPushPayload { /// Headers depending on the authorization scheme and encryption standard. pub crypto_headers: Vec<(&'static str, String)>, /// The encryption standard. - pub content_encoding: &'static str, + pub content_encoding: ContentEncoding, } #[derive(Debug, Deserialize, Serialize, Clone, Eq, PartialEq, Ord, PartialOrd, Default, Hash)] @@ -145,16 +145,12 @@ 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. + /// Aes128gcm is preferred, if the browser supports it. 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 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. + /// Builds and if set, encrypts the payload. pub fn build(self) -> Result { let endpoint: Uri = self.subscription_info.endpoint.parse()?; diff --git a/src/vapid/signer.rs b/src/vapid/signer.rs index ea2171a9..46d77098 100644 --- a/src/vapid/signer.rs +++ b/src/vapid/signer.rs @@ -45,6 +45,13 @@ impl VapidSigner { claims.custom.remove("exp"); } + // Add sub if not provided as some browsers (like firefox) require it even though the API doesn't say its needed >:[ + if !claims.custom.contains_key("sub") { + claims = claims.with_subject("mailto:example@example.com".to_string()); + } + + log::trace!("Using jwt: {:?}", claims); + let auth_k = key.public_key(); //Generate JWT signature