Skip to content

Commit

Permalink
Add aesgcm support back, and fix some firefox bugs (#44)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
andyblarblar and github-actions[bot] authored Apr 3, 2023
1 parent 5f1af38 commit 994b683
Show file tree
Hide file tree
Showing 7 changed files with 146 additions and 63 deletions.
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>", "Andrew Ealovega <[email protected]>"]
license = "Apache-2.0"
homepage = "https://github.com/pimeys/rust-web-push"
Expand All @@ -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"
Expand Down
68 changes: 29 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -64,7 +32,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>
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,
Expand Down Expand Up @@ -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
--------

Expand Down
5 changes: 3 additions & 2 deletions examples/simple_send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>
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(
Expand All @@ -47,8 +47,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>

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();
Expand Down
2 changes: 1 addition & 1 deletion src/clients/request_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
113 changes: 101 additions & 12 deletions src/http_ece.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,29 @@
//! Payload encryption algorithm
use ece::encrypt;

use crate::error::WebPushError;
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.
Expand Down Expand Up @@ -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::AesGcmEncryptedBlock> {
ece::legacy::encrypt_aesgcm(self.peer_public_key, self.peer_secret, content)
}
}

#[cfg(test)]
Expand Down Expand Up @@ -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<VapidSignature>, encoding: ContentEncoding) -> WebPushPayload {
let p256dh = base64::decode_config(
"BLMbF9ffKBiWQLCKvTHb6LO8Nb6dcUh6TItC455vu2kElga6PQvUmaFyCdykxY2nOSSL3yKgfbmFLRTUaGv4yV8",
Expand All @@ -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<sig_t>[^,]*), k=(?P<sig_k>[^,]*)").unwrap();
Expand All @@ -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<sig_t>[^,]*), k=(?P<sig_k>[^,]*)").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());
}
}
10 changes: 3 additions & 7 deletions src/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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<WebPushMessage, WebPushError> {
let endpoint: Uri = self.subscription_info.endpoint.parse()?;

Expand Down
7 changes: 7 additions & 0 deletions src/vapid/signer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:[email protected]".to_string());
}

log::trace!("Using jwt: {:?}", claims);

let auth_k = key.public_key();

//Generate JWT signature
Expand Down

0 comments on commit 994b683

Please sign in to comment.