├── .envrc ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .vscode └── settings.json ├── Cargo.toml ├── LICENSE ├── README.md ├── examples └── simple-send │ ├── Cargo.toml │ └── src │ └── main.rs ├── resources ├── vapid_test_key.der └── vapid_test_key.pem ├── rustfmt.toml ├── shell.nix └── src ├── clients ├── hyper_client.rs ├── isahc_client.rs ├── mod.rs └── request_builder.rs ├── error.rs ├── http_ece.rs ├── lib.rs ├── message.rs └── vapid ├── builder.rs ├── key.rs ├── mod.rs └── signer.rs /.envrc: -------------------------------------------------------------------------------- 1 | if command -v nix-shell &> /dev/null 2 | then 3 | use nix 4 | fi 5 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | linux: 9 | strategy: 10 | matrix: 11 | toolchain: 12 | - "1.80" 13 | - "stable" 14 | - "beta" 15 | - "nightly" 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: dtolnay/rust-toolchain@master 20 | with: 21 | toolchain: ${{ matrix.toolchain }} 22 | - run: cargo build --manifest-path examples/simple-send/Cargo.toml 23 | - run: cargo test --no-default-features 24 | - run: cargo doc --no-default-features 25 | - run: cargo test --no-default-features --features isahc-client 26 | - run: cargo doc --no-default-features --features isahc-client 27 | - run: cargo test --no-default-features --features hyper-client 28 | - run: cargo doc --no-default-features --features hyper-client 29 | - run: cargo test --no-default-features --features isahc-client,hyper-client 30 | - run: cargo doc --no-default-features --features isahc-client,hyper-client 31 | windows: 32 | runs-on: windows-latest 33 | steps: 34 | - uses: actions/checkout@v4 35 | - run: vcpkg integrate install 36 | - run: vcpkg install openssl:x64-windows-static-md 37 | - uses: dtolnay/rust-toolchain@stable 38 | - run: cargo test 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | Cargo.lock 3 | examples/*.json 4 | examples/*.pem 5 | .direnv/ 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.enabledLanguageIds": [ 3 | "asciidoc", 4 | "c", 5 | "cpp", 6 | "csharp", 7 | "css", 8 | "git-commit", 9 | "go", 10 | "handlebars", 11 | "html", 12 | "jade", 13 | "javascript", 14 | "javascriptreact", 15 | "json", 16 | "latex", 17 | "less", 18 | "markdown", 19 | "php", 20 | "plaintext", 21 | "pug", 22 | "python", 23 | "restructuredtext", 24 | "rust", 25 | "scss", 26 | "text", 27 | "typescript", 28 | "typescriptreact", 29 | "yml" 30 | ] 31 | } -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "web-push" 3 | description = "Web push notification client with support for http-ece encryption and VAPID authentication." 4 | version = "0.11.0" 5 | authors = ["Julius de Bruijn ", "Andrew Ealovega "] 6 | license = "Apache-2.0" 7 | homepage = "https://github.com/pimeys/rust-web-push" 8 | repository = "https://github.com/pimeys/rust-web-push" 9 | documentation = "https://docs.rs/web-push/" 10 | readme = "README.md" 11 | keywords = ["web-push", "http-ece", "vapid"] 12 | categories = ["web-programming", "asynchronous"] 13 | rust-version = "1.80" 14 | edition = "2021" 15 | 16 | [features] 17 | default = ["isahc-client"] 18 | isahc-client = ["isahc", "futures-lite/futures-io"] 19 | hyper-client = ["hyper", "hyper-tls"] # use features = ["hyper-client"], default-features = false for about 300kb size decrease 20 | 21 | [dependencies] 22 | hyper = { version = "0.14", features = ["client", "http1"], optional = true } 23 | hyper-tls = { version = "0.5", optional = true } 24 | isahc = { version = "1.4.0", optional = true } 25 | futures-lite = { version = "2.5.0", optional = true } 26 | http = "0.2" 27 | serde = "1.0" 28 | serde_json = "1.0" 29 | serde_derive = "1.0" 30 | jwt-simple = { version = "0.12.11", default-features = false, features = ["pure-rust"] } 31 | ece = "2.2" 32 | pem = "3.0.4" 33 | sec1_decode = "0.1.0" 34 | chrono = "0.4" 35 | log = "0.4" 36 | async-trait = "0.1" 37 | ct-codecs = "1.1.3" 38 | 39 | [dev-dependencies] 40 | regex = "1.5" 41 | tokio = { version = "1", features = ["macros", "rt-multi-thread"] } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2017 Julius de Bruijn 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Rust Web Push 2 | ============= 3 | 4 | [![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) 5 | [![crates.io](https://img.shields.io/crates/d/web-push)](https://crates.io/crates/web_push) 6 | [![docs.rs](https://docs.rs/web-push/badge.svg)](https://docs.rs/web-push) 7 | 8 | This crate implements the server half of the web push API, in Rust! 9 | 10 | For more background on the web push framework itself, please 11 | reference [this excellent document.](https://web.dev/notifications/) 12 | 13 | ## Requirements 14 | 15 | - Clients require an async executor. 16 | - OpenSSL is required for compilation. You must install it on your host or use the `vendored` feature of the [openssl](https://docs.rs/openssl/) crate. 17 | 18 | ## Migration notes 19 | 20 | This library is still in active development, and will have breaking changes in accordance with semver. Please view the 21 | GitHub release notes for detailed notes. 22 | 23 | Example 24 | -------- 25 | 26 | ```rust 27 | use web_push::*; 28 | use std::fs::File; 29 | 30 | #[tokio::main] 31 | async fn main() -> Result<(), Box> { 32 | let endpoint = "https://updates.push.services.mozilla.com/wpush/v1/..."; 33 | let p256dh = "key_from_browser_as_base64"; 34 | let auth = "auth_from_browser_as_base64"; 35 | 36 | //You would likely get this by deserializing a browser `pushSubscription` object via serde. 37 | let subscription_info = SubscriptionInfo::new( 38 | endpoint, 39 | p256dh, 40 | auth 41 | ); 42 | 43 | //Read signing material for payload. 44 | let file = File::open("private_key.pem").unwrap(); 45 | let mut sig_builder = VapidSignatureBuilder::from_pem(file, &subscription_info)?.build()?; 46 | 47 | //Now add payload and encrypt. 48 | let mut builder = WebPushMessageBuilder::new(&subscription_info); 49 | let content = "Encrypted payload to be sent in the notification".as_bytes(); 50 | builder.set_payload(ContentEncoding::Aes128Gcm, content); 51 | builder.set_vapid_signature(sig_builder); 52 | 53 | let client = IsahcWebPushClient::new()?; 54 | 55 | //Finally, send the notification! 56 | client.send(builder.build()?).await?; 57 | Ok(()) 58 | } 59 | ``` 60 | 61 | VAPID 62 | ----- 63 | 64 | VAPID authentication prevents unknown sources sending notifications to the client and is required by all current 65 | browsers when sending a payload. 66 | 67 | The private key to be used by the server can be generated with OpenSSL: 68 | 69 | ``` 70 | openssl ecparam -genkey -name prime256v1 -out private_key.pem 71 | ``` 72 | 73 | To derive a public key from the just-generated private key, to be used in the JavaScript client: 74 | 75 | ``` 76 | openssl ec -in private_key.pem -pubout -outform DER|tail -c 65|base64|tr '/+' '_-'|tr -d '\n=' 77 | ``` 78 | 79 | The signature is created with `VapidSignatureBuilder`. It automatically adds the required claims `aud` and `exp`. Adding 80 | these claims to the builder manually will override the default values. 81 | 82 | ## Using the example program 83 | 84 | To send a web push from command line, first subscribe to receive push notifications with your browser and store the 85 | subscription info into a json file. It should have the following content: 86 | 87 | ``` json 88 | { 89 | "endpoint": "https://updates.push.services.mozilla.com/wpush/v1/TOKEN", 90 | "keys": { 91 | "auth": "####secret####", 92 | "p256dh": "####public_key####" 93 | } 94 | } 95 | ``` 96 | 97 | Google has 98 | [good instructions](https://developers.google.com/web/fundamentals/push-notifications/subscribing-a-user) for building a 99 | frontend to receive notifications. 100 | 101 | Store the subscription info to `test.json` and send a notification with 102 | `cd examples/simple-send && cargo run -- -v ../../private_key.pem -f ../../test.json -p "It works!"`. 103 | 104 | Overview 105 | -------- 106 | 107 | Currently, the crate implements 108 | [RFC8188](https://datatracker.ietf.org/doc/html/rfc8188) content encryption for notification payloads. This is done by 109 | delegating encryption to mozilla's [ece crate](https://crates.io/crates/ece). Our security is thus tied 110 | to [theirs](https://github.com/mozilla/rust-ece/issues/18). The default client is built 111 | on [isahc](https://crates.io/crates/isahc), but can be swapped out with a hyper based client using the 112 | `hyper-client` feature. Custom clients can be made using the `request_builder` module. 113 | 114 | Library tested with Google's and Mozilla's push notification services. Also verified to work on Edge. 115 | 116 | Openssl is needed to build. Install `openssl-dev` or equivalent on *nix, or `openssl` using `vcpkg` on Windows. A nix 117 | script is also available. 118 | 119 | If installing on Windows, this is the exact command: 120 | 121 | ```shell 122 | vcpkg integrate install 123 | vcpkg install openssl:x64-windows-static-md 124 | ``` 125 | 126 | Debugging 127 | -------- 128 | If you get an error or the push notification doesn't work you can try to debug using the following instructions: 129 | 130 | Add the following to your Cargo.toml: 131 | 132 | ```cargo 133 | log = "0.4" 134 | pretty_env_logger = "0.3" 135 | ``` 136 | 137 | Add the following to your main.rs: 138 | 139 | ```rust 140 | extern crate pretty_env_logger; 141 | 142 | // ... 143 | fn main() { 144 | pretty_env_logger::init(); 145 | // ... 146 | } 147 | ``` 148 | 149 | Or use any other logging library compatible with https://docs.rs/log/ 150 | 151 | Then run your program with the following environment variables: 152 | 153 | ```bash 154 | RUST_LOG="web_push::client=trace" cargo run 155 | ``` 156 | 157 | This should print some more information about the requests to the push service which may aid you or somebody else in 158 | finding the error. 159 | -------------------------------------------------------------------------------- /examples/simple-send/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "example-simple-send" 3 | version = "0.1.0" 4 | edition = "2021" 5 | publish = false 6 | 7 | [dependencies] 8 | argparse = "0.2" 9 | serde_json = "1" 10 | tokio = { version = "1", features = ["macros", "rt-multi-thread"] } 11 | web-push = { path = "../..", features = ["isahc-client"] } 12 | -------------------------------------------------------------------------------- /examples/simple-send/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{fs::File, io::Read}; 2 | 3 | use argparse::{ArgumentParser, Store, StoreOption}; 4 | use web_push::*; 5 | 6 | #[tokio::main] 7 | async fn main() -> Result<(), Box> { 8 | let mut subscription_info_file = String::new(); 9 | let mut vapid_private_key: Option = None; 10 | let mut push_payload: Option = None; 11 | let mut encoding: Option = None; 12 | let mut ttl: Option = None; 13 | 14 | { 15 | let mut ap = ArgumentParser::new(); 16 | ap.set_description("A web push sender"); 17 | 18 | ap.refer(&mut vapid_private_key).add_option( 19 | &["-v", "--vapid_key"], 20 | StoreOption, 21 | "A NIST P256 EC private key to create a VAPID signature", 22 | ); 23 | 24 | ap.refer(&mut encoding).add_option( 25 | &["-e", "--encoding"], 26 | StoreOption, 27 | "Content Encoding Scheme : 'aes128gcm' or 'aesgcm'", 28 | ); 29 | 30 | ap.refer(&mut subscription_info_file).add_option( 31 | &["-f", "--subscription_info_file"], 32 | Store, 33 | "Subscription info JSON file, https://developers.google.com/web/updates/2016/03/web-push-encryption", 34 | ); 35 | 36 | ap.refer(&mut push_payload) 37 | .add_option(&["-p", "--push_payload"], StoreOption, "Push notification content"); 38 | 39 | ap.refer(&mut ttl) 40 | .add_option(&["-t", "--time_to_live"], StoreOption, "TTL of the notification"); 41 | 42 | ap.parse_args_or_exit(); 43 | } 44 | 45 | let mut file = File::open(subscription_info_file).unwrap(); 46 | let mut contents = String::new(); 47 | file.read_to_string(&mut contents).unwrap(); 48 | 49 | let ece_scheme = match encoding.as_deref() { 50 | Some("aes128gcm") => ContentEncoding::Aes128Gcm, 51 | Some("aesgcm") => ContentEncoding::AesGcm, 52 | None => ContentEncoding::Aes128Gcm, 53 | Some(_) => panic!("Content encoding can only be 'aes128gcm' or 'aesgcm'"), 54 | }; 55 | 56 | let subscription_info: SubscriptionInfo = serde_json::from_str(&contents).unwrap(); 57 | 58 | let mut builder = WebPushMessageBuilder::new(&subscription_info); 59 | 60 | if let Some(ref payload) = push_payload { 61 | builder.set_payload(ece_scheme, payload.as_bytes()); 62 | } else { 63 | builder.set_payload(ece_scheme, "Hello world!".as_bytes()); 64 | } 65 | 66 | if let Some(time) = ttl { 67 | builder.set_ttl(time); 68 | } 69 | 70 | if let Some(ref vapid_file) = vapid_private_key { 71 | let file = File::open(vapid_file).unwrap(); 72 | 73 | let mut sig_builder = VapidSignatureBuilder::from_pem(file, &subscription_info).unwrap(); 74 | 75 | sig_builder.add_claim("sub", "mailto:test@example.com"); 76 | sig_builder.add_claim("foo", "bar"); 77 | sig_builder.add_claim("omg", 123); 78 | 79 | let signature = sig_builder.build().unwrap(); 80 | 81 | builder.set_vapid_signature(signature); 82 | }; 83 | 84 | let client = IsahcWebPushClient::new()?; 85 | 86 | let result = client.send(builder.build()?).await; 87 | 88 | if let Err(error) = result { 89 | println!("An error occured: {:?}", error); 90 | } 91 | 92 | Ok(()) 93 | } 94 | -------------------------------------------------------------------------------- /resources/vapid_test_key.der: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimeys/rust-web-push/8de73e2e6d3e56786561924dd132a76ea18ae042/resources/vapid_test_key.der -------------------------------------------------------------------------------- /resources/vapid_test_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN EC PRIVATE KEY----- 2 | MHcCAQEEIMwug/U2ds75hkEIeou9s0kj1ziCJETswt5S9ztJ2L5SoAoGCCqGSM49 3 | AwEHoUQDQgAEyjUeooXqyQxljKSu17126pjAEPTyYNApO6dGQl0PexMn0T7LI3qw 4 | mU9ZOko2Gn7LYp5LqgA0cX6rfDftsKVvtQ== 5 | -----END EC PRIVATE KEY----- 6 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2021" 2 | max_width = 120 3 | imports_granularity = "Crate" 4 | group_imports = "StdExternalCrate" 5 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import {} }: 2 | 3 | with pkgs; 4 | 5 | mkShell { 6 | buildInputs = with pkgs; [ 7 | openssl 8 | pkg-config 9 | ]; 10 | } 11 | -------------------------------------------------------------------------------- /src/clients/hyper_client.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use http::header::RETRY_AFTER; 3 | use hyper::{body::HttpBody, client::HttpConnector, Body, Client, Request as HttpRequest}; 4 | use hyper_tls::HttpsConnector; 5 | 6 | use crate::{ 7 | clients::{request_builder, WebPushClient, MAX_RESPONSE_SIZE}, 8 | error::{RetryAfter, WebPushError}, 9 | message::WebPushMessage, 10 | }; 11 | 12 | /// An async client for sending the notification payload. 13 | /// 14 | /// This client is thread-safe. Clones of this client will share the same underlying resources, 15 | /// so cloning is a cheap and effective method to provide access to the client. 16 | /// 17 | /// This client is [`hyper`](https://crates.io/crates/hyper) based, and will only work in Tokio contexts. 18 | #[derive(Clone)] 19 | pub struct HyperWebPushClient { 20 | client: Client>, 21 | } 22 | 23 | impl Default for HyperWebPushClient { 24 | fn default() -> Self { 25 | Self::new() 26 | } 27 | } 28 | 29 | impl From>> for HyperWebPushClient { 30 | /// Creates a new client from a custom hyper HTTP client. 31 | fn from(client: Client>) -> Self { 32 | Self { client } 33 | } 34 | } 35 | 36 | impl HyperWebPushClient { 37 | /// Creates a new client. 38 | pub fn new() -> Self { 39 | Self { 40 | client: Client::builder().build(HttpsConnector::new()), 41 | } 42 | } 43 | } 44 | 45 | #[async_trait] 46 | impl WebPushClient for HyperWebPushClient { 47 | /// Sends a notification. Never times out. 48 | async fn send(&self, message: WebPushMessage) -> Result<(), WebPushError> { 49 | trace!("Message: {:?}", message); 50 | 51 | let request: HttpRequest = request_builder::build_request(message); 52 | 53 | debug!("Request: {:?}", request); 54 | 55 | let requesting = self.client.request(request); 56 | 57 | let response = requesting.await?; 58 | 59 | trace!("Response: {:?}", response); 60 | 61 | let retry_after = response 62 | .headers() 63 | .get(RETRY_AFTER) 64 | .and_then(|ra| ra.to_str().ok()) 65 | .and_then(RetryAfter::from_str); 66 | 67 | let response_status = response.status(); 68 | trace!("Response status: {}", response_status); 69 | 70 | let mut chunks = response.into_body(); 71 | let mut body = Vec::new(); 72 | while let Some(chunk) = chunks.data().await { 73 | body.extend(&chunk?); 74 | if body.len() > MAX_RESPONSE_SIZE { 75 | return Err(WebPushError::ResponseTooLarge); 76 | } 77 | } 78 | trace!("Body: {:?}", body); 79 | 80 | trace!("Body text: {:?}", std::str::from_utf8(&body)); 81 | 82 | let response = request_builder::parse_response(response_status, body.to_vec()); 83 | 84 | debug!("Response: {:?}", response); 85 | 86 | if let Err(WebPushError::ServerError { 87 | retry_after: None, 88 | info, 89 | }) = response 90 | { 91 | Err(WebPushError::ServerError { retry_after, info }) 92 | } else { 93 | Ok(response?) 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/clients/isahc_client.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use futures_lite::AsyncReadExt; 3 | use http::header::RETRY_AFTER; 4 | use isahc::HttpClient; 5 | 6 | use crate::{ 7 | clients::{request_builder, WebPushClient, MAX_RESPONSE_SIZE}, 8 | error::{RetryAfter, WebPushError}, 9 | message::WebPushMessage, 10 | }; 11 | 12 | /// An async client for sending the notification payload. This client is expensive to create, and 13 | /// should be reused. 14 | /// 15 | /// This client is thread-safe. Clones of this client will share the same underlying resources, 16 | /// so cloning is a cheap and effective method to provide access to the client. 17 | /// 18 | /// This client is built on [`isahc`](https://crates.io/crates/isahc), and will therefore work on any async executor. 19 | #[derive(Clone)] 20 | pub struct IsahcWebPushClient { 21 | client: HttpClient, 22 | } 23 | 24 | impl Default for IsahcWebPushClient { 25 | fn default() -> Self { 26 | Self::new().unwrap() 27 | } 28 | } 29 | 30 | impl From for IsahcWebPushClient { 31 | /// Creates a new client from a custom Isahc HTTP client. 32 | fn from(client: HttpClient) -> Self { 33 | Self { client } 34 | } 35 | } 36 | 37 | impl IsahcWebPushClient { 38 | /// Creates a new client. Can fail under resource depletion. 39 | pub fn new() -> Result { 40 | Ok(Self { 41 | client: HttpClient::new()?, 42 | }) 43 | } 44 | } 45 | 46 | #[async_trait] 47 | impl WebPushClient for IsahcWebPushClient { 48 | /// Sends a notification. Never times out. 49 | async fn send(&self, message: WebPushMessage) -> Result<(), WebPushError> { 50 | trace!("Message: {:?}", message); 51 | 52 | let request = request_builder::build_request::(message); 53 | 54 | trace!("Request: {:?}", request); 55 | 56 | let requesting = self.client.send_async(request); 57 | 58 | let response = requesting.await?; 59 | 60 | trace!("Response: {:?}", response); 61 | 62 | let retry_after = response 63 | .headers() 64 | .get(RETRY_AFTER) 65 | .and_then(|ra| ra.to_str().ok()) 66 | .and_then(RetryAfter::from_str); 67 | 68 | let response_status = response.status(); 69 | trace!("Response status: {}", response_status); 70 | 71 | let mut body = Vec::new(); 72 | if response 73 | .into_body() 74 | .take(MAX_RESPONSE_SIZE as u64 + 1) 75 | .read_to_end(&mut body) 76 | .await? 77 | > MAX_RESPONSE_SIZE 78 | { 79 | return Err(WebPushError::ResponseTooLarge); 80 | } 81 | trace!("Body: {:?}", body); 82 | 83 | trace!("Body text: {:?}", std::str::from_utf8(&body)); 84 | 85 | let response = request_builder::parse_response(response_status, body.to_vec()); 86 | 87 | trace!("Response: {:?}", response); 88 | 89 | if let Err(WebPushError::ServerError { 90 | retry_after: None, 91 | info, 92 | }) = response 93 | { 94 | Err(WebPushError::ServerError { retry_after, info }) 95 | } else { 96 | Ok(response?) 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/clients/mod.rs: -------------------------------------------------------------------------------- 1 | //! Contains implementations of web push clients. 2 | //! 3 | //! [`request_builder`] contains the functions used to send and consume push http messages. 4 | //! This module should be consumed by each client, by using [`http`]'s flexible api. 5 | 6 | use async_trait::async_trait; 7 | 8 | use crate::{WebPushError, WebPushMessage}; 9 | 10 | pub mod request_builder; 11 | 12 | #[cfg(feature = "hyper-client")] 13 | pub mod hyper_client; 14 | 15 | #[cfg(feature = "isahc-client")] 16 | pub mod isahc_client; 17 | 18 | const MAX_RESPONSE_SIZE: usize = 64 * 1024; 19 | 20 | /// An async client for sending the notification payload. 21 | /// Other features, such as thread safety, may vary by implementation. 22 | #[async_trait] 23 | pub trait WebPushClient { 24 | /// Sends a notification. Never times out. 25 | async fn send(&self, message: WebPushMessage) -> Result<(), WebPushError>; 26 | } 27 | -------------------------------------------------------------------------------- /src/clients/request_builder.rs: -------------------------------------------------------------------------------- 1 | //! Functions used to send and consume push http messages. 2 | //! This module can be used to build custom clients. 3 | 4 | use http::{ 5 | header::{CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE}, 6 | Request, StatusCode, 7 | }; 8 | 9 | use crate::{ 10 | error::{ErrorInfo, WebPushError}, 11 | message::WebPushMessage, 12 | }; 13 | 14 | /// Builds the request to send to the push service. 15 | /// 16 | /// This function is generic over the request body, this means that you can swap out client implementations 17 | /// even if they use different body types. 18 | /// 19 | /// # Example 20 | /// 21 | /// ```no_run 22 | /// # use web_push::{SubscriptionInfo, WebPushMessageBuilder}; 23 | /// # use web_push::request_builder::build_request; 24 | /// let info = SubscriptionInfo::new( 25 | /// "http://google.com", 26 | /// "BLMbF9ffKBiWQLCKvTHb6LO8Nb6dcUh6TItC455vu2kElga6PQvUmaFyCdykxY2nOSSL3yKgfbmFLRTUaGv4yV8", 27 | /// "xS03Fi5ErfTNH_l9WHE9Ig", 28 | /// ); 29 | /// 30 | /// let mut builder = WebPushMessageBuilder::new(&info); 31 | /// 32 | /// // Build the request for isahc 33 | /// # #[cfg(feature = "isahc-client")] 34 | /// let request = build_request::(builder.build().unwrap()); 35 | /// // Send using a http client 36 | /// ``` 37 | pub fn build_request(message: WebPushMessage) -> Request 38 | where 39 | T: From> + From<&'static str>, //This bound can be reduced to a &[u8] instead of str if needed 40 | { 41 | let mut builder = Request::builder() 42 | .method("POST") 43 | .uri(message.endpoint) 44 | .header("TTL", format!("{}", message.ttl).as_bytes()); 45 | 46 | if let Some(urgency) = message.urgency { 47 | builder = builder.header("Urgency", urgency.to_string()); 48 | } 49 | 50 | if let Some(topic) = message.topic { 51 | builder = builder.header("Topic", topic); 52 | } 53 | 54 | if let Some(payload) = message.payload { 55 | builder = builder 56 | .header(CONTENT_ENCODING, payload.content_encoding.to_str()) 57 | .header(CONTENT_LENGTH, format!("{}", payload.content.len() as u64).as_bytes()) 58 | .header(CONTENT_TYPE, "application/octet-stream"); 59 | 60 | for (k, v) in payload.crypto_headers.into_iter() { 61 | let v: &str = v.as_ref(); 62 | builder = builder.header(k, v); 63 | } 64 | 65 | builder.body(payload.content.into()).unwrap() 66 | } else { 67 | builder.body("".into()).unwrap() 68 | } 69 | } 70 | 71 | /// Parses the response from the push service, and will return `Err` if the request was bad. 72 | pub fn parse_response(response_status: StatusCode, body: Vec) -> Result<(), WebPushError> { 73 | if response_status.is_success() { 74 | return Ok(()); 75 | } 76 | 77 | let info: ErrorInfo = serde_json::from_slice(&body).unwrap_or_else(|_| ErrorInfo { 78 | code: response_status.as_u16(), 79 | errno: 999, 80 | error: "unknown error".into(), 81 | message: String::from_utf8(body).unwrap_or_else(|_| "-".into()), 82 | }); 83 | 84 | match response_status { 85 | StatusCode::UNAUTHORIZED => Err(WebPushError::Unauthorized(info)), 86 | StatusCode::GONE => Err(WebPushError::EndpointNotValid(info)), 87 | StatusCode::NOT_FOUND => Err(WebPushError::EndpointNotFound(info)), 88 | StatusCode::PAYLOAD_TOO_LARGE => Err(WebPushError::PayloadTooLarge), 89 | StatusCode::BAD_REQUEST => Err(WebPushError::BadRequest(info)), 90 | status if status.is_server_error() => Err(WebPushError::ServerError { 91 | retry_after: None, 92 | info, 93 | }), 94 | _ => Err(WebPushError::Other(info)), 95 | } 96 | } 97 | 98 | #[cfg(test)] 99 | mod tests { 100 | use http::Uri; 101 | 102 | use crate::{ 103 | clients::request_builder::*, error::WebPushError, http_ece::ContentEncoding, message::WebPushMessageBuilder, 104 | Urgency, 105 | }; 106 | 107 | #[cfg(feature = "isahc-client")] 108 | #[test] 109 | fn builds_a_correct_request_with_empty_payload() { 110 | //This *was* a real token 111 | let sub = serde_json::json!({"endpoint":"https://fcm.googleapis.com/fcm/send/eKClHsXFm9E:APA91bH2x3gNOMv4dF1lQfCgIfOet8EngqKCAUS5DncLOd5hzfSUxcjigIjw9ws-bqa-KmohqiTOcgepAIVO03N39dQfkEkopubML_m3fyvF03pV9_JCB7SxpUjcFmBSVhCaWS6m8l7x", 112 | "expirationTime":null, 113 | "keys":{"p256dh": 114 | "BGa4N1PI79lboMR_YrwCiCsgp35DRvedt7opHcf0yM3iOBTSoQYqQLwWxAfRKE6tsDnReWmhsImkhDF_DBdkNSU", 115 | "auth":"EvcWjEgzr4rbvhfi3yds0A"} 116 | }); 117 | 118 | let info = serde_json::from_value(sub).unwrap(); 119 | 120 | let mut builder = WebPushMessageBuilder::new(&info); 121 | 122 | builder.set_ttl(420); 123 | builder.set_urgency(Urgency::VeryLow); 124 | builder.set_topic("some-topic".into()); 125 | 126 | let request = build_request::(builder.build().unwrap()); 127 | let ttl = request.headers().get("TTL").unwrap().to_str().unwrap(); 128 | let urgency = request.headers().get("Urgency").unwrap().to_str().unwrap(); 129 | let topic = request.headers().get("Topic").unwrap().to_str().unwrap(); 130 | let expected_uri: Uri = "fcm.googleapis.com".parse().unwrap(); 131 | 132 | assert_eq!("420", ttl); 133 | assert_eq!("very-low", urgency); 134 | assert_eq!("some-topic", topic); 135 | assert_eq!(expected_uri.host(), request.uri().host()); 136 | } 137 | 138 | #[cfg(feature = "isahc-client")] 139 | #[test] 140 | fn builds_a_correct_request_with_payload() { 141 | //This *was* a real token 142 | let sub = serde_json::json!({"endpoint":"https://fcm.googleapis.com/fcm/send/eKClHsXFm9E:APA91bH2x3gNOMv4dF1lQfCgIfOet8EngqKCAUS5DncLOd5hzfSUxcjigIjw9ws-bqa-KmohqiTOcgepAIVO03N39dQfkEkopubML_m3fyvF03pV9_JCB7SxpUjcFmBSVhCaWS6m8l7x", 143 | "expirationTime":null, 144 | "keys":{"p256dh": 145 | "BGa4N1PI79lboMR_YrwCiCsgp35DRvedt7opHcf0yM3iOBTSoQYqQLwWxAfRKE6tsDnReWmhsImkhDF_DBdkNSU", 146 | "auth":"EvcWjEgzr4rbvhfi3yds0A"} 147 | }); 148 | 149 | let info = serde_json::from_value(sub).unwrap(); 150 | 151 | let mut builder = WebPushMessageBuilder::new(&info); 152 | 153 | builder.set_payload(ContentEncoding::Aes128Gcm, "test".as_bytes()); 154 | 155 | let request = build_request::(builder.build().unwrap()); 156 | 157 | let encoding = request.headers().get("Content-Encoding").unwrap().to_str().unwrap(); 158 | 159 | let length = request.headers().get("Content-Length").unwrap(); 160 | let expected_uri: Uri = "fcm.googleapis.com".parse().unwrap(); 161 | 162 | assert_eq!("230", length); 163 | assert_eq!("aes128gcm", encoding); 164 | assert_eq!(expected_uri.host(), request.uri().host()); 165 | } 166 | 167 | #[test] 168 | fn parses_a_successful_response_correctly() { 169 | assert!(matches!(parse_response(StatusCode::OK, vec![]), Ok(()))); 170 | } 171 | 172 | #[test] 173 | fn parses_an_unauthorized_response_correctly() { 174 | assert!(matches!( 175 | parse_response(StatusCode::UNAUTHORIZED, vec![]), 176 | Err(WebPushError::Unauthorized(_)) 177 | )); 178 | } 179 | 180 | #[test] 181 | fn parses_a_gone_response_correctly() { 182 | assert!(matches!( 183 | parse_response(StatusCode::GONE, vec![]), 184 | Err(WebPushError::EndpointNotValid(_)) 185 | )); 186 | } 187 | 188 | #[test] 189 | fn parses_a_not_found_response_correctly() { 190 | assert!(matches!( 191 | parse_response(StatusCode::NOT_FOUND, vec![]), 192 | Err(WebPushError::EndpointNotFound(_)) 193 | )); 194 | } 195 | 196 | #[test] 197 | fn parses_a_payload_too_large_response_correctly() { 198 | assert!(matches!( 199 | parse_response(StatusCode::PAYLOAD_TOO_LARGE, vec![]), 200 | Err(WebPushError::PayloadTooLarge) 201 | )); 202 | } 203 | 204 | #[test] 205 | fn parses_a_server_error_response_correctly() { 206 | assert!(matches!( 207 | parse_response(StatusCode::INTERNAL_SERVER_ERROR, vec![]), 208 | Err(WebPushError::ServerError { .. }) 209 | )); 210 | } 211 | 212 | #[test] 213 | fn parses_a_bad_request_response_with_no_body_correctly() { 214 | assert!(matches!( 215 | parse_response(StatusCode::BAD_REQUEST, vec![]), 216 | Err(WebPushError::BadRequest(_)) 217 | )); 218 | } 219 | 220 | #[test] 221 | fn parses_a_bad_request_response_with_body_correctly() { 222 | let json = r#" 223 | { 224 | "code": 400, 225 | "errno": 103, 226 | "error": "FooBar", 227 | "message": "No message found" 228 | } 229 | "#; 230 | 231 | assert!(matches!( 232 | parse_response(StatusCode::BAD_REQUEST, json.as_bytes().to_vec()), 233 | Err(WebPushError::BadRequest(ErrorInfo { 234 | code: 400, 235 | errno: 103, 236 | error: _, 237 | message: _, 238 | })), 239 | )); 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | convert::From, 3 | error::Error, 4 | fmt, 5 | io::Error as IoError, 6 | string::FromUtf8Error, 7 | time::{Duration, SystemTime}, 8 | }; 9 | 10 | use http::uri::InvalidUri; 11 | use serde_json::error::Error as JsonError; 12 | 13 | #[derive(Debug, Clone, Serialize, Deserialize)] 14 | pub struct ErrorInfo { 15 | pub code: u16, 16 | pub errno: u16, 17 | pub error: String, 18 | pub message: String, 19 | } 20 | 21 | impl fmt::Display for ErrorInfo { 22 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 23 | write!( 24 | f, 25 | "code {}, errno {}: {} ({})", 26 | self.code, self.errno, self.error, self.message 27 | ) 28 | } 29 | } 30 | 31 | #[derive(Debug)] 32 | pub enum WebPushError { 33 | /// An unknown error happened while encrypting or sending the message 34 | Unspecified, 35 | /// Please provide valid credentials to send the notification 36 | Unauthorized(ErrorInfo), 37 | /// Request was badly formed 38 | BadRequest(ErrorInfo), 39 | /// Contains an optional `Duration`, until the user can retry the request 40 | ServerError { 41 | retry_after: Option, 42 | info: ErrorInfo, 43 | }, 44 | /// The feature is not implemented yet 45 | NotImplemented(ErrorInfo), 46 | /// The provided URI is invalid 47 | InvalidUri, 48 | /// The URL specified is no longer valid and should no longer be used 49 | EndpointNotValid(ErrorInfo), 50 | /// The URL specified is invalid and should not be used again 51 | EndpointNotFound(ErrorInfo), 52 | /// Maximum allowed payload size is 3800 characters 53 | PayloadTooLarge, 54 | /// Error in reading a file 55 | Io(IoError), 56 | /// Make sure the message was addressed to a registration token whose 57 | /// package name matches the value passed in the request (Google). 58 | InvalidPackageName, 59 | /// The TTL value provided was not valid or was not provided 60 | InvalidTtl, 61 | /// The Topic value provided was invalid 62 | InvalidTopic, 63 | /// The request was missing required crypto keys 64 | MissingCryptoKeys, 65 | /// One or more of the crypto key elements are invalid. 66 | InvalidCryptoKeys, 67 | /// Corrupted response data 68 | InvalidResponse, 69 | /// A claim had invalid data 70 | InvalidClaims, 71 | /// Response from push endpoint was too large 72 | ResponseTooLarge, 73 | Other(ErrorInfo), 74 | } 75 | 76 | impl Error for WebPushError {} 77 | 78 | impl From for WebPushError { 79 | fn from(_: JsonError) -> WebPushError { 80 | WebPushError::InvalidResponse 81 | } 82 | } 83 | 84 | impl From for WebPushError { 85 | fn from(_: FromUtf8Error) -> WebPushError { 86 | WebPushError::InvalidResponse 87 | } 88 | } 89 | 90 | impl From for WebPushError { 91 | fn from(_: InvalidUri) -> WebPushError { 92 | WebPushError::InvalidUri 93 | } 94 | } 95 | 96 | #[cfg(feature = "hyper-client")] 97 | impl From for WebPushError { 98 | fn from(_: hyper::Error) -> Self { 99 | Self::Unspecified 100 | } 101 | } 102 | 103 | #[cfg(feature = "isahc-client")] 104 | impl From for WebPushError { 105 | fn from(_: isahc::Error) -> Self { 106 | Self::Unspecified 107 | } 108 | } 109 | 110 | impl From for WebPushError { 111 | fn from(err: IoError) -> WebPushError { 112 | WebPushError::Io(err) 113 | } 114 | } 115 | 116 | impl WebPushError { 117 | pub fn short_description(&self) -> &'static str { 118 | match *self { 119 | WebPushError::Unspecified => "unspecified", 120 | WebPushError::Unauthorized(_) => "unauthorized", 121 | WebPushError::BadRequest(_) => "bad_request", 122 | WebPushError::ServerError { .. } => "server_error", 123 | WebPushError::NotImplemented(_) => "not_implemented", 124 | WebPushError::InvalidUri => "invalid_uri", 125 | WebPushError::EndpointNotValid(_) => "endpoint_not_valid", 126 | WebPushError::EndpointNotFound(_) => "endpoint_not_found", 127 | WebPushError::PayloadTooLarge => "payload_too_large", 128 | WebPushError::InvalidPackageName => "invalid_package_name", 129 | WebPushError::InvalidTtl => "invalid_ttl", 130 | WebPushError::InvalidTopic => "invalid_topic", 131 | WebPushError::InvalidResponse => "invalid_response", 132 | WebPushError::MissingCryptoKeys => "missing_crypto_keys", 133 | WebPushError::InvalidCryptoKeys => "invalid_crypto_keys", 134 | WebPushError::Io(_) => "io_error", 135 | WebPushError::Other(_) => "other", 136 | WebPushError::InvalidClaims => "invalidClaims", 137 | WebPushError::ResponseTooLarge => "response_too_large", 138 | } 139 | } 140 | } 141 | 142 | impl fmt::Display for WebPushError { 143 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 144 | match self { 145 | WebPushError::Unspecified => write!(f, "unspecified error"), 146 | WebPushError::Unauthorized(info) => write!(f, "unauthorized: {}", info), 147 | WebPushError::BadRequest(info) => write!(f, "bad request: {}", info), 148 | WebPushError::ServerError { info, .. } => write!(f, "server error: {}", info), 149 | WebPushError::PayloadTooLarge => write!(f, "maximum payload size of 3070 characters exceeded"), 150 | WebPushError::InvalidUri => write!(f, "invalid uri provided"), 151 | WebPushError::NotImplemented(info) => write!(f, "not implemented: {}", info), 152 | WebPushError::EndpointNotValid(info) => write!(f, "endpoint not valid: {}", info), 153 | WebPushError::EndpointNotFound(info) => write!(f, "endpoint not found: {}", info), 154 | WebPushError::Io(err) => write!(f, "i/o error: {}", err), 155 | WebPushError::InvalidPackageName => write!( 156 | f, 157 | "package name of registration token does not match package name provided in the request" 158 | ), 159 | WebPushError::InvalidTtl => write!(f, "invalid or missing ttl value"), 160 | WebPushError::InvalidTopic => write!(f, "invalid topic value"), 161 | WebPushError::InvalidResponse => write!(f, "could not parse response data"), 162 | WebPushError::MissingCryptoKeys => write!(f, "request is missing cryptographic keys"), 163 | WebPushError::InvalidCryptoKeys => write!(f, "request has invalid cryptographic keys"), 164 | WebPushError::Other(info) => write!(f, "other: {}", info), 165 | WebPushError::InvalidClaims => write!(f, "at least one jwt claim was invalid"), 166 | WebPushError::ResponseTooLarge => write!(f, "response from push endpoint was too large"), 167 | } 168 | } 169 | } 170 | 171 | pub struct RetryAfter; 172 | impl RetryAfter { 173 | pub fn from_str(header_value: &str) -> Option { 174 | if let Ok(seconds) = header_value.parse::() { 175 | Some(Duration::from_secs(seconds)) 176 | } else { 177 | chrono::DateTime::parse_from_rfc2822(header_value) 178 | .map(|date_time| { 179 | let systime: SystemTime = date_time.into(); 180 | 181 | systime 182 | .duration_since(SystemTime::now()) 183 | .unwrap_or_else(|_| Duration::new(0, 0)) 184 | }) 185 | .ok() 186 | } 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/http_ece.rs: -------------------------------------------------------------------------------- 1 | //! Payload encryption algorithm 2 | 3 | use ct_codecs::{Base64UrlSafeNoPadding, Decoder, Encoder}; 4 | use ece::encrypt; 5 | 6 | use crate::{error::WebPushError, message::WebPushPayload, vapid::VapidSignature}; 7 | 8 | /// Content encoding profiles. 9 | #[derive(Debug, PartialEq, Copy, Clone, Default)] 10 | pub enum ContentEncoding { 11 | //Make sure this enum remains exhaustive as that allows for easier migrations to new versions. 12 | #[default] 13 | Aes128Gcm, 14 | /// 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. 15 | AesGcm, 16 | } 17 | 18 | impl ContentEncoding { 19 | /// Gets the associated string for this content encoding, as would be used in the content-encoding header. 20 | pub fn to_str(&self) -> &'static str { 21 | match &self { 22 | ContentEncoding::Aes128Gcm => "aes128gcm", 23 | ContentEncoding::AesGcm => "aesgcm", 24 | } 25 | } 26 | } 27 | 28 | /// Struct for handling payload encryption. 29 | pub struct HttpEce<'a> { 30 | peer_public_key: &'a [u8], 31 | peer_secret: &'a [u8], 32 | encoding: ContentEncoding, 33 | vapid_signature: Option, 34 | } 35 | 36 | impl<'a> HttpEce<'a> { 37 | /// Create a new encryptor. 38 | /// 39 | /// `peer_public_key` is the `p256dh` and `peer_secret` the `auth` from 40 | /// browser subscription info. 41 | pub fn new( 42 | encoding: ContentEncoding, 43 | peer_public_key: &'a [u8], 44 | peer_secret: &'a [u8], 45 | vapid_signature: Option, 46 | ) -> HttpEce<'a> { 47 | HttpEce { 48 | peer_public_key, 49 | peer_secret, 50 | encoding, 51 | vapid_signature, 52 | } 53 | } 54 | 55 | /// Encrypts a payload. The maximum length for the payload is 3800 56 | /// characters, which is the largest that works with Google's and Mozilla's 57 | /// push servers. 58 | pub fn encrypt(&self, content: &'a [u8]) -> Result { 59 | if content.len() > 3052 { 60 | return Err(WebPushError::PayloadTooLarge); 61 | } 62 | 63 | //Add more encoding standards to this match as they are created. 64 | match self.encoding { 65 | ContentEncoding::Aes128Gcm => { 66 | let result = encrypt(self.peer_public_key, self.peer_secret, content); 67 | 68 | let mut headers = Vec::new(); 69 | 70 | self.add_vapid_headers(&mut headers); 71 | 72 | match result { 73 | Ok(data) => Ok(WebPushPayload { 74 | content: data, 75 | crypto_headers: headers, 76 | content_encoding: self.encoding, 77 | }), 78 | _ => Err(WebPushError::InvalidCryptoKeys), 79 | } 80 | } 81 | ContentEncoding::AesGcm => { 82 | let result = self.aesgcm_encrypt(content); 83 | 84 | let data = result.map_err(|_| WebPushError::InvalidCryptoKeys)?; 85 | 86 | // Get headers exclusive to the aesgcm scheme (Crypto-Key ect.) 87 | let mut headers = data.headers(self.vapid_signature.as_ref().map(|v| v.auth_k.as_slice())); 88 | 89 | self.add_vapid_headers(&mut headers); 90 | 91 | // ECE library base64 encodes content in aesgcm, but not aes128gcm, so decode base64 here to match the 128 API 92 | let data = Base64UrlSafeNoPadding::decode_to_vec(data.body(), None) 93 | .expect("ECE library should always base64 encode"); 94 | 95 | Ok(WebPushPayload { 96 | content: data, 97 | crypto_headers: headers, 98 | content_encoding: self.encoding, 99 | }) 100 | } 101 | } 102 | } 103 | 104 | /// Adds VAPID authorisation header to headers, if VAPID is being used. 105 | fn add_vapid_headers(&self, headers: &mut Vec<(&str, String)>) { 106 | //VAPID uses a special Authorisation header, which contains a ecdhsa key and a jwt. 107 | if let Some(signature) = &self.vapid_signature { 108 | headers.push(( 109 | "Authorization", 110 | format!( 111 | "vapid t={}, k={}", 112 | signature.auth_t, 113 | Base64UrlSafeNoPadding::encode_to_string(&signature.auth_k) 114 | .expect("encoding a valid auth_k cannot overflow") 115 | ), 116 | )); 117 | } 118 | } 119 | 120 | /// Encrypts the content using the aesgcm encoding. 121 | /// 122 | /// This is extracted into a function for testing. 123 | fn aesgcm_encrypt(&self, content: &[u8]) -> ece::Result { 124 | ece::legacy::encrypt_aesgcm(self.peer_public_key, self.peer_secret, content) 125 | } 126 | } 127 | 128 | #[cfg(test)] 129 | mod tests { 130 | use ct_codecs::{Base64UrlSafeNoPadding, Decoder}; 131 | use regex::Regex; 132 | 133 | use crate::{ 134 | error::WebPushError, 135 | http_ece::{ContentEncoding, HttpEce}, 136 | VapidSignature, WebPushPayload, 137 | }; 138 | 139 | #[test] 140 | fn test_payload_too_big() { 141 | let p256dh = Base64UrlSafeNoPadding::decode_to_vec( 142 | "BLMaF9ffKBiWQLCKvTHb6LO8Nb6dcUh6TItC455vu2kElga6PQvUmaFyCdykxY2nOSSL3yKgfbmFLRTUaGv4yV8", 143 | None, 144 | ) 145 | .unwrap(); 146 | let auth = Base64UrlSafeNoPadding::decode_to_vec("xS03Fj5ErfTNH_l9WHE9Ig", None).unwrap(); 147 | let http_ece = HttpEce::new(ContentEncoding::Aes128Gcm, &p256dh, &auth, None); 148 | //This content is one above limit. 149 | let content = [0u8; 3801]; 150 | 151 | assert!(matches!(http_ece.encrypt(&content), Err(WebPushError::PayloadTooLarge))); 152 | } 153 | 154 | /// Tests that the content encryption is properly reversible while using aes128gcm. 155 | #[test] 156 | fn test_payload_encrypts_128() { 157 | let (key, auth) = ece::generate_keypair_and_auth_secret().unwrap(); 158 | let p_key = key.raw_components().unwrap(); 159 | let p_key = p_key.public_key(); 160 | 161 | let http_ece = HttpEce::new(ContentEncoding::Aes128Gcm, p_key, &auth, None); 162 | let plaintext = "Hello world!"; 163 | let ciphertext = http_ece.encrypt(plaintext.as_bytes()).unwrap(); 164 | 165 | assert_ne!(plaintext.as_bytes(), ciphertext.content); 166 | 167 | assert_eq!( 168 | String::from_utf8(ece::decrypt(&key.raw_components().unwrap(), &auth, &ciphertext.content).unwrap()) 169 | .unwrap(), 170 | plaintext 171 | ) 172 | } 173 | 174 | /// Tests that the content encryption is properly reversible while using aesgcm. 175 | #[test] 176 | fn test_payload_encrypts() { 177 | let (key, auth) = ece::generate_keypair_and_auth_secret().unwrap(); 178 | let p_key = key.raw_components().unwrap(); 179 | let p_key = p_key.public_key(); 180 | 181 | let http_ece = HttpEce::new(ContentEncoding::AesGcm, p_key, &auth, None); 182 | let plaintext = "Hello world!"; 183 | let ciphertext = http_ece.aesgcm_encrypt(plaintext.as_bytes()).unwrap(); 184 | 185 | assert_ne!(plaintext, ciphertext.body()); 186 | 187 | assert_eq!( 188 | String::from_utf8(ece::legacy::decrypt_aesgcm(&key.raw_components().unwrap(), &auth, &ciphertext).unwrap()) 189 | .unwrap(), 190 | plaintext 191 | ) 192 | } 193 | 194 | fn setup_payload(vapid_signature: Option, encoding: ContentEncoding) -> WebPushPayload { 195 | let p256dh = Base64UrlSafeNoPadding::decode_to_vec( 196 | "BLMbF9ffKBiWQLCKvTHb6LO8Nb6dcUh6TItC455vu2kElga6PQvUmaFyCdykxY2nOSSL3yKgfbmFLRTUaGv4yV8", 197 | None, 198 | ) 199 | .unwrap(); 200 | let auth = Base64UrlSafeNoPadding::decode_to_vec("xS03Fi5ErfTNH_l9WHE9Ig", None).unwrap(); 201 | 202 | let http_ece = HttpEce::new(encoding, &p256dh, &auth, vapid_signature); 203 | let content = "Hello, world!".as_bytes(); 204 | 205 | http_ece.encrypt(content).unwrap() 206 | } 207 | 208 | #[test] 209 | fn test_aes128gcm_headers_no_vapid() { 210 | let wp_payload = setup_payload(None, ContentEncoding::Aes128Gcm); 211 | assert_eq!(wp_payload.crypto_headers.len(), 0); 212 | } 213 | 214 | #[test] 215 | fn test_aesgcm_headers_no_vapid() { 216 | let wp_payload = setup_payload(None, ContentEncoding::AesGcm); 217 | assert_eq!(wp_payload.crypto_headers.len(), 2); 218 | } 219 | 220 | #[test] 221 | fn test_aes128gcm_headers_vapid() { 222 | let auth_re = Regex::new(r"vapid t=(?P[^,]*), k=(?P[^,]*)").unwrap(); 223 | let vapid_signature = VapidSignature { 224 | auth_t: String::from("foo"), 225 | auth_k: String::from("bar").into_bytes(), 226 | }; 227 | let wp_payload = setup_payload(Some(vapid_signature), ContentEncoding::Aes128Gcm); 228 | assert_eq!(wp_payload.crypto_headers.len(), 1); 229 | let auth = wp_payload.crypto_headers[0].clone(); 230 | assert_eq!(auth.0, "Authorization"); 231 | assert!(auth_re.captures(&auth.1).is_some()); 232 | } 233 | 234 | #[test] 235 | fn test_aesgcm_headers_vapid() { 236 | let auth_re = Regex::new(r"vapid t=(?P[^,]*), k=(?P[^,]*)").unwrap(); 237 | let vapid_signature = VapidSignature { 238 | auth_t: String::from("foo"), 239 | auth_k: String::from("bar").into_bytes(), 240 | }; 241 | let wp_payload = setup_payload(Some(vapid_signature), ContentEncoding::AesGcm); 242 | // Should have Authorization, Crypto-key, and Encryption 243 | assert_eq!(wp_payload.crypto_headers.len(), 3); 244 | let auth = wp_payload.crypto_headers[2].clone(); 245 | assert_eq!(auth.0, "Authorization"); 246 | assert!(auth_re.captures(&auth.1).is_some()); 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! # Web Push 2 | //! 3 | //! A library for creating and sending push notifications to a web browser. For 4 | //! content payload encryption it uses [RFC8188](https://datatracker.ietf.org/doc/html/rfc8188). 5 | //! The client is asynchronous and can run on any executor. An optional [`hyper`](https://crates.io/crates/hyper) based client is 6 | //! available with the feature `hyper-client`. 7 | //! 8 | //! # Example 9 | //! 10 | //! ```no_run 11 | //! # use web_push::*; 12 | //! # use std::fs::File; 13 | //! # #[tokio::main] 14 | //! # async fn main() -> Result<(), Box> { 15 | //! let endpoint = "https://updates.push.services.mozilla.com/wpush/v1/..."; 16 | //! let p256dh = "key_from_browser_as_base64"; 17 | //! let auth = "auth_from_browser_as_base64"; 18 | //! 19 | //! // You would likely get this by deserializing a browser `pushSubscription` object. 20 | //! let subscription_info = SubscriptionInfo::new( 21 | //! endpoint, 22 | //! p256dh, 23 | //! auth 24 | //! ); 25 | //! 26 | //! // Read signing material for payload. 27 | //! let file = File::open("private.pem").unwrap(); 28 | //! let mut sig_builder = VapidSignatureBuilder::from_pem(file, &subscription_info)?.build()?; 29 | //! 30 | //! // Now add payload and encrypt. 31 | //! let mut builder = WebPushMessageBuilder::new(&subscription_info); 32 | //! let content = "Encrypted payload to be sent in the notification".as_bytes(); 33 | //! builder.set_payload(ContentEncoding::Aes128Gcm, content); 34 | //! builder.set_vapid_signature(sig_builder); 35 | //! 36 | //! # #[cfg(feature = "isahc-client")] 37 | //! let client = IsahcWebPushClient::new()?; 38 | //! 39 | //! // Finally, send the notification! 40 | //! # #[cfg(feature = "isahc-client")] 41 | //! client.send(builder.build()?).await?; 42 | //! # Ok(()) 43 | //! # } 44 | //! ``` 45 | 46 | #[macro_use] 47 | extern crate log; 48 | #[macro_use] 49 | extern crate serde_derive; 50 | 51 | #[cfg(feature = "hyper-client")] 52 | pub use crate::clients::hyper_client::HyperWebPushClient; 53 | #[cfg(feature = "isahc-client")] 54 | pub use crate::clients::isahc_client::IsahcWebPushClient; 55 | pub use crate::{ 56 | clients::{request_builder, WebPushClient}, 57 | error::WebPushError, 58 | http_ece::ContentEncoding, 59 | message::{SubscriptionInfo, SubscriptionKeys, Urgency, WebPushMessage, WebPushMessageBuilder, WebPushPayload}, 60 | vapid::{builder::PartialVapidSignatureBuilder, VapidSignature, VapidSignatureBuilder}, 61 | }; 62 | 63 | mod clients; 64 | mod error; 65 | mod http_ece; 66 | mod message; 67 | mod vapid; 68 | -------------------------------------------------------------------------------- /src/message.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{Display, Formatter}; 2 | 3 | use ct_codecs::{Base64UrlSafeNoPadding, Decoder}; 4 | use http::uri::Uri; 5 | 6 | use crate::{ 7 | error::WebPushError, 8 | http_ece::{ContentEncoding, HttpEce}, 9 | vapid::VapidSignature, 10 | }; 11 | 12 | /// Encryption keys from the client. 13 | #[derive(Debug, Deserialize, Serialize, Clone, Eq, PartialEq, Ord, PartialOrd, Default, Hash)] 14 | pub struct SubscriptionKeys { 15 | /// The public key. Base64-encoded, URL-safe alphabet, no padding. 16 | pub p256dh: String, 17 | /// Authentication secret. Base64-encoded, URL-safe alphabet, no padding. 18 | pub auth: String, 19 | } 20 | 21 | /// Client info for sending the notification. Maps the values from browser's 22 | /// subscription info JSON data (AKA pushSubscription object). 23 | /// 24 | /// Client pushSubscription objects can be directly deserialized into this struct using serde. 25 | #[derive(Debug, Deserialize, Serialize, Clone, Eq, PartialEq, Ord, PartialOrd, Default, Hash)] 26 | pub struct SubscriptionInfo { 27 | /// The endpoint URI for sending the notification. 28 | pub endpoint: String, 29 | /// The encryption key and secret for payload encryption. 30 | pub keys: SubscriptionKeys, 31 | } 32 | 33 | impl SubscriptionInfo { 34 | /// A constructor function to create a new `SubscriptionInfo`, if not using 35 | /// Serde's serialization. 36 | pub fn new(endpoint: S, p256dh: S, auth: S) -> SubscriptionInfo 37 | where 38 | S: Into, 39 | { 40 | SubscriptionInfo { 41 | endpoint: endpoint.into(), 42 | keys: SubscriptionKeys { 43 | p256dh: p256dh.into(), 44 | auth: auth.into(), 45 | }, 46 | } 47 | } 48 | } 49 | 50 | /// The push content payload, already in an encrypted form. 51 | #[derive(Debug, PartialEq)] 52 | pub struct WebPushPayload { 53 | /// Encrypted content data. 54 | pub content: Vec, 55 | /// Headers depending on the authorization scheme and encryption standard. 56 | pub crypto_headers: Vec<(&'static str, String)>, 57 | /// The encryption standard. 58 | pub content_encoding: ContentEncoding, 59 | } 60 | 61 | #[derive(Debug, Deserialize, Serialize, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Default, Hash)] 62 | #[serde(rename_all = "kebab-case")] 63 | pub enum Urgency { 64 | VeryLow, 65 | Low, 66 | #[default] 67 | Normal, 68 | High, 69 | } 70 | 71 | impl Display for Urgency { 72 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 73 | let str = match self { 74 | Urgency::VeryLow => "very-low", 75 | Urgency::Low => "low", 76 | Urgency::Normal => "normal", 77 | Urgency::High => "high", 78 | }; 79 | 80 | f.write_str(str) 81 | } 82 | } 83 | 84 | /// Everything needed to send a push notification to the user. 85 | #[derive(Debug)] 86 | pub struct WebPushMessage { 87 | /// The endpoint URI where to send the payload. 88 | pub endpoint: Uri, 89 | /// Time to live, how long the message should wait in the server if user is 90 | /// not online. Some services require this value to be set. 91 | pub ttl: u32, 92 | /// The urgency of the message (very-low | low | normal | high) 93 | pub urgency: Option, 94 | /// The topic of the mssage 95 | pub topic: Option, 96 | /// The encrypted request payload, if sending any data. 97 | pub payload: Option, 98 | } 99 | 100 | struct WebPushPayloadBuilder<'a> { 101 | pub content: &'a [u8], 102 | pub encoding: ContentEncoding, 103 | } 104 | 105 | /// The main class for creating a notification payload. 106 | pub struct WebPushMessageBuilder<'a> { 107 | subscription_info: &'a SubscriptionInfo, 108 | payload: Option>, 109 | ttl: u32, 110 | urgency: Option, 111 | topic: Option, 112 | vapid_signature: Option, 113 | } 114 | 115 | impl<'a> WebPushMessageBuilder<'a> { 116 | /// Creates a builder for generating the web push payload. 117 | /// 118 | /// All parameters are from the subscription info given by browser when 119 | /// subscribing to push notifications. 120 | pub fn new(subscription_info: &'a SubscriptionInfo) -> WebPushMessageBuilder<'a> { 121 | WebPushMessageBuilder { 122 | subscription_info, 123 | ttl: 2_419_200, 124 | urgency: None, 125 | topic: None, 126 | payload: None, 127 | vapid_signature: None, 128 | } 129 | } 130 | 131 | /// How long the server should keep the message if it cannot be delivered 132 | /// currently. If not set, the message is deleted immediately on failed 133 | /// delivery. 134 | pub fn set_ttl(&mut self, ttl: u32) { 135 | self.ttl = ttl; 136 | } 137 | 138 | /// Urgency indicates to the push service how important a message is to the 139 | /// user. This can be used by the push service to help conserve the battery 140 | /// life of a user's device by only waking up for important messages when 141 | /// battery is low. 142 | /// Possible values are 'very-low', 'low', 'normal' and 'high'. 143 | pub fn set_urgency(&mut self, urgency: Urgency) { 144 | self.urgency = Some(urgency); 145 | } 146 | 147 | /// Assign a topic to the push message. A message that has been stored 148 | /// by the push service can be replaced with new content if the message 149 | /// has been assigned a topic. If the user agent is offline during the 150 | /// time that the push messages are sent, updating a push message avoid 151 | /// the situation where outdated or redundant messages are sent to the 152 | /// user agent. A message with a topic replaces any outstanding push 153 | /// messages with an identical topic. It is an arbitrary string 154 | /// consisting of at most 32 base64url characters. 155 | pub fn set_topic(&mut self, topic: String) { 156 | self.topic = Some(topic); 157 | } 158 | 159 | /// Add a VAPID signature to the request. To be generated with the 160 | /// [VapidSignatureBuilder](struct.VapidSignatureBuilder.html). 161 | pub fn set_vapid_signature(&mut self, vapid_signature: VapidSignature) { 162 | self.vapid_signature = Some(vapid_signature); 163 | } 164 | 165 | /// If set, the client will get content in the notification. Has a maximum size of 166 | /// 3800 characters. 167 | /// 168 | /// Aes128gcm is preferred, if the browser supports it. 169 | pub fn set_payload(&mut self, encoding: ContentEncoding, content: &'a [u8]) { 170 | self.payload = Some(WebPushPayloadBuilder { content, encoding }); 171 | } 172 | 173 | /// Builds and if set, encrypts the payload. 174 | pub fn build(self) -> Result { 175 | let endpoint: Uri = self.subscription_info.endpoint.parse()?; 176 | let topic: Option = self 177 | .topic 178 | .map(|topic| { 179 | if topic.len() > 32 { 180 | Err(WebPushError::InvalidTopic) 181 | } else if topic.chars().all(is_base64url_char) { 182 | Ok(topic) 183 | } else { 184 | Err(WebPushError::InvalidTopic) 185 | } 186 | }) 187 | .transpose()?; 188 | 189 | if let Some(payload) = self.payload { 190 | let p256dh = Base64UrlSafeNoPadding::decode_to_vec(&self.subscription_info.keys.p256dh, None) 191 | .map_err(|_| WebPushError::InvalidCryptoKeys)?; 192 | let auth = Base64UrlSafeNoPadding::decode_to_vec(&self.subscription_info.keys.auth, None) 193 | .map_err(|_| WebPushError::InvalidCryptoKeys)?; 194 | 195 | let http_ece = HttpEce::new(payload.encoding, &p256dh, &auth, self.vapid_signature); 196 | 197 | Ok(WebPushMessage { 198 | endpoint, 199 | ttl: self.ttl, 200 | urgency: self.urgency, 201 | topic, 202 | payload: Some(http_ece.encrypt(payload.content)?), 203 | }) 204 | } else { 205 | Ok(WebPushMessage { 206 | endpoint, 207 | ttl: self.ttl, 208 | urgency: self.urgency, 209 | topic, 210 | payload: None, 211 | }) 212 | } 213 | } 214 | } 215 | 216 | fn is_base64url_char(c: char) -> bool { 217 | c.is_ascii_uppercase() || c.is_ascii_lowercase() || c.is_ascii_digit() || (c == '-' || c == '_') 218 | } 219 | -------------------------------------------------------------------------------- /src/vapid/builder.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::BTreeMap, io::Read}; 2 | 3 | use ct_codecs::Base64UrlSafeNoPadding; 4 | use http::uri::Uri; 5 | use jwt_simple::prelude::*; 6 | use serde_json::Value; 7 | 8 | use crate::{ 9 | error::WebPushError, 10 | message::SubscriptionInfo, 11 | vapid::{signer::Claims, VapidKey, VapidSignature, VapidSigner}, 12 | }; 13 | 14 | /// A VAPID signature builder for generating an optional signature to the 15 | /// request. This encryption is required for payloads in all current and future browsers. 16 | /// 17 | /// To communicate with the site, one needs to generate a private key to keep in 18 | /// the server and derive a public key from the generated private key for the 19 | /// client. 20 | /// 21 | /// Private key generation: 22 | /// 23 | /// ```bash,ignore 24 | /// openssl ecparam -name prime256v1 -genkey -noout -out private.pem 25 | /// ``` 26 | /// 27 | /// To derive a public key out of generated private key: 28 | /// 29 | /// ```bash,ignore 30 | /// openssl ec -in private.pem -pubout -out vapid_public.pem 31 | /// ``` 32 | /// 33 | /// To get the byte form of the public key for the JavaScript client: 34 | /// 35 | /// ```bash,ignore 36 | /// openssl ec -in private.pem -text -noout -conv_form uncompressed 37 | /// ``` 38 | /// 39 | /// ... or a base64-encoded string, which the client should convert into 40 | /// byte form before using: 41 | /// 42 | /// ```bash,ignore 43 | /// openssl ec -in private.pem -pubout -outform DER|tail -c 65|base64|tr '/+' '_-'|tr -d '\n' 44 | /// ``` 45 | /// 46 | /// The above commands can be done in code using [`PartialVapidSignatureBuilder::get_public_key`], then base64 URL safe 47 | /// encoding as well. 48 | /// 49 | /// To create a VAPID signature: 50 | /// 51 | /// ```no_run 52 | /// # extern crate web_push; 53 | /// # use web_push::*; 54 | /// # use std::fs::File; 55 | /// # fn main () { 56 | /// //You would get this as a `pushSubscription` object from the client. They need your public key to get that object. 57 | /// let subscription_info = SubscriptionInfo { 58 | /// keys: SubscriptionKeys { 59 | /// p256dh: String::from("something"), 60 | /// auth: String::from("secret"), 61 | /// }, 62 | /// endpoint: String::from("https://mozilla.rules/something"), 63 | /// }; 64 | /// 65 | /// let file = File::open("private.pem").unwrap(); 66 | /// 67 | /// let mut sig_builder = VapidSignatureBuilder::from_pem(file, &subscription_info).unwrap(); 68 | /// 69 | /// //These fields are optional, and likely unneeded for most uses. 70 | /// sig_builder.add_claim("sub", "mailto:test@example.com"); 71 | /// sig_builder.add_claim("foo", "bar"); 72 | /// sig_builder.add_claim("omg", 123); 73 | /// 74 | /// let signature = sig_builder.build().unwrap(); 75 | /// # } 76 | /// ``` 77 | pub struct VapidSignatureBuilder<'a> { 78 | claims: Claims, 79 | key: VapidKey, 80 | subscription_info: &'a SubscriptionInfo, 81 | } 82 | 83 | impl<'a> VapidSignatureBuilder<'a> { 84 | /// Creates a new builder from a PEM formatted private key. 85 | /// 86 | /// # Details 87 | /// 88 | /// The input can be either a pkcs8 formatted PEM, denoted by a -----BEGIN PRIVATE KEY------ 89 | /// header, or a SEC1 formatted PEM, denoted by a -----BEGIN EC PRIVATE KEY------ header. 90 | pub fn from_pem( 91 | pk_pem: R, 92 | subscription_info: &'a SubscriptionInfo, 93 | ) -> Result, WebPushError> { 94 | let pr_key = Self::read_pem(pk_pem)?; 95 | 96 | Ok(Self::from_ec(pr_key, subscription_info)) 97 | } 98 | 99 | /// Creates a new builder from a PEM formatted private key. This function doesn't take a subscription, 100 | /// allowing the reuse of one builder for multiple messages by cloning the resulting builder. 101 | /// 102 | /// # Details 103 | /// 104 | /// The input can be either a pkcs8 formatted PEM, denoted by a -----BEGIN PRIVATE KEY------ 105 | /// header, or a SEC1 formatted PEM, denoted by a -----BEGIN EC PRIVATE KEY------ header. 106 | pub fn from_pem_no_sub(pk_pem: R) -> Result { 107 | let pr_key = Self::read_pem(pk_pem)?; 108 | 109 | Ok(PartialVapidSignatureBuilder { 110 | key: VapidKey::new(pr_key), 111 | }) 112 | } 113 | 114 | /// Creates a new builder from a DER formatted private key. 115 | pub fn from_der( 116 | mut pk_der: R, 117 | subscription_info: &'a SubscriptionInfo, 118 | ) -> Result, WebPushError> { 119 | let mut der_key: Vec = Vec::new(); 120 | pk_der.read_to_end(&mut der_key)?; 121 | 122 | Ok(Self::from_ec( 123 | ES256KeyPair::from_bytes( 124 | &sec1_decode::parse_der(&der_key) 125 | .map_err(|_| WebPushError::InvalidCryptoKeys)? 126 | .key, 127 | ) 128 | .map_err(|_| WebPushError::InvalidCryptoKeys)?, 129 | subscription_info, 130 | )) 131 | } 132 | 133 | /// Creates a new builder from a DER formatted private key. This function doesn't take a subscription, 134 | /// allowing the reuse of one builder for multiple messages by cloning the resulting builder. 135 | pub fn from_der_no_sub(mut pk_der: R) -> Result { 136 | let mut der_key: Vec = Vec::new(); 137 | pk_der.read_to_end(&mut der_key)?; 138 | 139 | Ok(PartialVapidSignatureBuilder { 140 | key: VapidKey::new( 141 | ES256KeyPair::from_bytes( 142 | &sec1_decode::parse_der(&der_key) 143 | .map_err(|_| WebPushError::InvalidCryptoKeys)? 144 | .key, 145 | ) 146 | .map_err(|_| WebPushError::InvalidCryptoKeys)?, 147 | ), 148 | }) 149 | } 150 | 151 | /// Creates a new builder from a raw base64-encoded private key. This isn't the base64 from a key 152 | /// generated by openssl, but rather the literal bytes of the private key itself. This is the kind 153 | /// of key given to you by most VAPID key generator sites, and also the kind used in the API of other 154 | /// large web push libraries, such as PHP and Node. 155 | /// 156 | /// Base64 encoding must use URL-safe alphabet without padding. 157 | /// 158 | /// # Example 159 | /// 160 | /// ``` 161 | /// # use web_push::VapidSignatureBuilder; 162 | /// // Use `from_base64` here if you have a sub 163 | /// let builder = VapidSignatureBuilder::from_base64_no_sub("IQ9Ur0ykXoHS9gzfYX0aBjy9lvdrjx_PFUXmie9YRcY").unwrap(); 164 | /// ``` 165 | pub fn from_base64( 166 | encoded: &str, 167 | subscription_info: &'a SubscriptionInfo, 168 | ) -> Result, WebPushError> { 169 | let pr_key = ES256KeyPair::from_bytes( 170 | &Base64UrlSafeNoPadding::decode_to_vec(encoded, None).map_err(|_| WebPushError::InvalidCryptoKeys)?, 171 | ) 172 | .map_err(|_| WebPushError::InvalidCryptoKeys)?; 173 | 174 | Ok(Self::from_ec(pr_key, subscription_info)) 175 | } 176 | 177 | /// Creates a new builder from a raw base64-encoded private key. This function doesn't take a subscription, 178 | /// allowing the reuse of one builder for multiple messages by cloning the resulting builder. 179 | /// 180 | /// Base64 encoding must use URL-safe alphabet without padding. 181 | /// 182 | pub fn from_base64_no_sub(encoded: &str) -> Result { 183 | let pr_key = ES256KeyPair::from_bytes( 184 | &Base64UrlSafeNoPadding::decode_to_vec(encoded, None).map_err(|_| WebPushError::InvalidCryptoKeys)?, 185 | ) 186 | .map_err(|_| WebPushError::InvalidCryptoKeys)?; 187 | 188 | Ok(PartialVapidSignatureBuilder { 189 | key: VapidKey::new(pr_key), 190 | }) 191 | } 192 | 193 | /// Add a claim to the signature. Claims `aud` and `exp` are automatically 194 | /// added to the signature. Add them manually to override the default 195 | /// values. 196 | /// 197 | /// The function accepts any value that can be converted into a type JSON 198 | /// supports. 199 | pub fn add_claim(&mut self, key: &'a str, val: V) 200 | where 201 | V: Into, 202 | { 203 | self.claims.custom.insert(key.to_string(), val.into()); 204 | } 205 | 206 | /// Builds a signature to be used in [WebPushMessageBuilder](struct.WebPushMessageBuilder.html). 207 | pub fn build(self) -> Result { 208 | let endpoint: Uri = self.subscription_info.endpoint.parse()?; 209 | let signature = VapidSigner::sign(self.key, &endpoint, self.claims)?; 210 | 211 | Ok(signature) 212 | } 213 | 214 | fn from_ec(ec_key: ES256KeyPair, subscription_info: &'a SubscriptionInfo) -> VapidSignatureBuilder<'a> { 215 | VapidSignatureBuilder { 216 | claims: jwt_simple::prelude::Claims::with_custom_claims(BTreeMap::new(), Duration::from_hours(12)), 217 | key: VapidKey::new(ec_key), 218 | subscription_info, 219 | } 220 | } 221 | 222 | /// Reads the pem file as either format sec1 or pkcs8, then returns the decoded private key. 223 | pub(crate) fn read_pem(mut input: R) -> Result { 224 | let mut buffer = String::new(); 225 | input.read_to_string(&mut buffer)?; 226 | 227 | //Parse many PEM in the assumption of extra unneeded sections. 228 | let parsed = pem::parse_many(&buffer).map_err(|_| WebPushError::InvalidCryptoKeys)?; 229 | 230 | let found_pkcs8 = parsed.iter().any(|pem| pem.tag() == "PRIVATE KEY"); 231 | let found_sec1 = parsed.iter().any(|pem| pem.tag() == "EC PRIVATE KEY"); 232 | 233 | //Handle each kind of PEM file differently, as EC keys can be in SEC1 or PKCS8 format. 234 | if found_sec1 { 235 | let key = sec1_decode::parse_pem(buffer.as_bytes()).map_err(|_| WebPushError::InvalidCryptoKeys)?; 236 | Ok(ES256KeyPair::from_bytes(&key.key).map_err(|_| WebPushError::InvalidCryptoKeys)?) 237 | } else if found_pkcs8 { 238 | Ok(ES256KeyPair::from_pem(&buffer).map_err(|_| WebPushError::InvalidCryptoKeys)?) 239 | } else { 240 | Err(WebPushError::MissingCryptoKeys) 241 | } 242 | } 243 | } 244 | 245 | /// A [`VapidSignatureBuilder`] without VAPID subscription info. 246 | /// 247 | /// # Example 248 | /// 249 | /// ```no_run 250 | /// use web_push::{VapidSignatureBuilder, SubscriptionInfo}; 251 | /// 252 | /// let builder = VapidSignatureBuilder::from_pem_no_sub("Some PEM".as_bytes()).unwrap(); 253 | /// 254 | /// //Clone builder for each use of the same private key 255 | /// { 256 | /// //Pretend this changes for each connection 257 | /// let subscription_info = SubscriptionInfo::new( 258 | /// "https://updates.push.services.mozilla.com/wpush/v1/...", 259 | /// "BLMbF9ffKBiWQLCKvTHb6LO8Nb6dcUh6TItC455vu2kElga6PQvUmaFyCdykxY2nOSSL3yKgfbmFLRTUaGv4yV8", 260 | /// "xS03Fi5ErfTNH_l9WHE9Ig" 261 | /// ); 262 | /// 263 | /// let builder = builder.clone(); 264 | /// let sig = builder.add_sub_info(&subscription_info).build(); 265 | /// //Sign message ect. 266 | /// } 267 | /// 268 | /// ``` 269 | #[derive(Clone)] 270 | pub struct PartialVapidSignatureBuilder { 271 | key: VapidKey, 272 | } 273 | 274 | impl PartialVapidSignatureBuilder { 275 | /// Adds the VAPID subscription info for a particular client. 276 | pub fn add_sub_info(self, subscription_info: &SubscriptionInfo) -> VapidSignatureBuilder<'_> { 277 | VapidSignatureBuilder { 278 | key: self.key, 279 | claims: jwt_simple::prelude::Claims::with_custom_claims(BTreeMap::new(), Duration::from_hours(12)), 280 | subscription_info, 281 | } 282 | } 283 | 284 | /// Gets the uncompressed public key bytes derived from the private key used for this VAPID signature. 285 | /// 286 | /// Base64 encode these bytes to get the key to send to the client. 287 | pub fn get_public_key(&self) -> Vec { 288 | self.key.public_key() 289 | } 290 | } 291 | 292 | #[cfg(test)] 293 | mod tests { 294 | use ct_codecs::{Base64UrlSafeNoPadding, Encoder}; 295 | 296 | use crate::{message::SubscriptionInfo, vapid::VapidSignatureBuilder}; 297 | 298 | static PRIVATE_PEM: &[u8] = include_bytes!("../../resources/vapid_test_key.pem"); 299 | static PRIVATE_DER: &[u8] = include_bytes!("../../resources/vapid_test_key.der"); 300 | static PRIVATE_BASE64: &str = "IQ9Ur0ykXoHS9gzfYX0aBjy9lvdrjx_PFUXmie9YRcY"; 301 | 302 | fn example_subscription_info() -> SubscriptionInfo { 303 | serde_json::from_value( 304 | serde_json::json!({ 305 | "endpoint": "https://updates.push.services.mozilla.com/wpush/v2/gAAAAABaso4Vajy4STM25r5y5oFfyN451rUmES6mhQngxABxbZB5q_o75WpG25oKdrlrh9KdgWFKdYBc-buLPhvCTqR5KdsK8iCZHQume-ndtZJWKOgJbQ20GjbxHmAT1IAv8AIxTwHO-JTQ2Np2hwkKISp2_KUtpnmwFzglLP7vlCd16hTNJ2I", 306 | "keys": { 307 | "auth": "sBXU5_tIYz-5w7G2B25BEw", 308 | "p256dh": "BH1HTeKM7-NwaLGHEqxeu2IamQaVVLkcsFHPIHmsCnqxcBHPQBprF41bEMOr3O1hUQ2jU1opNEm1F_lZV_sxMP8" 309 | } 310 | }) 311 | ).unwrap() 312 | } 313 | 314 | #[test] 315 | fn test_builder_from_pem() { 316 | let subscription_info = example_subscription_info(); 317 | let builder = VapidSignatureBuilder::from_pem(PRIVATE_PEM, &subscription_info).unwrap(); 318 | let signature = builder.build().unwrap(); 319 | 320 | assert_eq!( 321 | "BMo1HqKF6skMZYykrte9duqYwBD08mDQKTunRkJdD3sTJ9E-yyN6sJlPWTpKNhp-y2KeS6oANHF-q3w37bClb7U", 322 | Base64UrlSafeNoPadding::encode_to_string(&signature.auth_k).unwrap() 323 | ); 324 | 325 | assert!(!signature.auth_t.is_empty()); 326 | } 327 | 328 | #[test] 329 | fn test_builder_from_der() { 330 | let subscription_info = example_subscription_info(); 331 | let builder = VapidSignatureBuilder::from_der(PRIVATE_DER, &subscription_info).unwrap(); 332 | let signature = builder.build().unwrap(); 333 | 334 | assert_eq!( 335 | "BMo1HqKF6skMZYykrte9duqYwBD08mDQKTunRkJdD3sTJ9E-yyN6sJlPWTpKNhp-y2KeS6oANHF-q3w37bClb7U", 336 | Base64UrlSafeNoPadding::encode_to_string(&signature.auth_k).unwrap() 337 | ); 338 | 339 | assert!(!signature.auth_t.is_empty()); 340 | } 341 | 342 | #[test] 343 | fn test_builder_from_base64() { 344 | let subscription_info = example_subscription_info(); 345 | let builder = VapidSignatureBuilder::from_base64(PRIVATE_BASE64, &subscription_info).unwrap(); 346 | let signature = builder.build().unwrap(); 347 | 348 | assert_eq!( 349 | "BMjQIp55pdbU8pfCBKyXcZjlmER_mXt5LqNrN1hrXbdBS5EnhIbMu3Au-RV53iIpztzNXkGI56BFB1udQ8Bq_H4", 350 | Base64UrlSafeNoPadding::encode_to_string(&signature.auth_k).unwrap() 351 | ); 352 | 353 | assert!(!signature.auth_t.is_empty()); 354 | } 355 | } 356 | -------------------------------------------------------------------------------- /src/vapid/key.rs: -------------------------------------------------------------------------------- 1 | use jwt_simple::prelude::*; 2 | 3 | /// The P256 curve key pair used for VAPID ECDHSA. 4 | pub struct VapidKey(pub ES256KeyPair); 5 | 6 | impl Clone for VapidKey { 7 | fn clone(&self) -> Self { 8 | VapidKey(ES256KeyPair::from_bytes(&self.0.to_bytes()).unwrap()) 9 | } 10 | } 11 | 12 | impl VapidKey { 13 | pub fn new(ec_key: ES256KeyPair) -> VapidKey { 14 | VapidKey(ec_key) 15 | } 16 | 17 | /// Gets the uncompressed public key bytes derived from this private key. 18 | pub fn public_key(&self) -> Vec { 19 | self.0.public_key().public_key().to_bytes_uncompressed() 20 | } 21 | } 22 | 23 | #[cfg(test)] 24 | mod tests { 25 | use std::fs::File; 26 | 27 | use crate::vapid::key::VapidKey; 28 | 29 | #[test] 30 | /// Tests that VapidKey derives the correct public key. 31 | fn test_public_key_derivation() { 32 | let f = File::open("resources/vapid_test_key.pem").unwrap(); 33 | let key = crate::VapidSignatureBuilder::read_pem(f).unwrap(); 34 | let key = VapidKey::new(key); 35 | 36 | assert_eq!( 37 | vec![ 38 | 4, 202, 53, 30, 162, 133, 234, 201, 12, 101, 140, 164, 174, 215, 189, 118, 234, 152, 192, 16, 244, 242, 39 | 96, 208, 41, 59, 167, 70, 66, 93, 15, 123, 19, 39, 209, 62, 203, 35, 122, 176, 153, 79, 89, 58, 74, 54, 40 | 26, 126, 203, 98, 158, 75, 170, 0, 52, 113, 126, 171, 124, 55, 237, 176, 165, 111, 181 41 | ], 42 | key.public_key() 43 | ); 44 | } 45 | 46 | #[test] 47 | /// Tests that VapidKey clones properly. 48 | fn test_key_clones() { 49 | let f = File::open("resources/vapid_test_key.pem").unwrap(); 50 | let key = crate::VapidSignatureBuilder::read_pem(f).unwrap(); 51 | let key = VapidKey::new(key); 52 | 53 | let key2 = key.clone(); 54 | 55 | assert_eq!(key.0.to_bytes(), key2.0.to_bytes()) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/vapid/mod.rs: -------------------------------------------------------------------------------- 1 | //! Contains tooling for signing with VAPID. 2 | 3 | pub use self::{builder::VapidSignatureBuilder, signer::VapidSignature}; 4 | use self::{key::VapidKey, signer::VapidSigner}; 5 | 6 | pub mod builder; 7 | mod key; 8 | mod signer; 9 | -------------------------------------------------------------------------------- /src/vapid/signer.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeMap; 2 | 3 | use http::uri::Uri; 4 | use jwt_simple::prelude::*; 5 | use serde_json::Value; 6 | 7 | use crate::{error::WebPushError, vapid::VapidKey}; 8 | 9 | /// A struct representing a VAPID signature. Should be generated using the 10 | /// [VapidSignatureBuilder](struct.VapidSignatureBuilder.html). 11 | #[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] 12 | pub struct VapidSignature { 13 | /// The signed JWT, base64-encoded 14 | pub auth_t: String, 15 | /// The public key bytes 16 | pub auth_k: Vec, 17 | } 18 | 19 | /// JWT claims object. Custom claims are implemented as a map. 20 | pub type Claims = JWTClaims>; 21 | 22 | pub struct VapidSigner {} 23 | 24 | impl VapidSigner { 25 | /// Create a signature with a given key. Sets the default audience from the 26 | /// endpoint host and sets the expiry in twelve hours. Values can be 27 | /// overwritten by adding the `aud` and `exp` claims. 28 | pub fn sign(key: VapidKey, endpoint: &Uri, mut claims: Claims) -> Result { 29 | if !claims.custom.contains_key("aud") { 30 | //Add audience if not provided. 31 | let audience = format!("{}://{}", endpoint.scheme_str().unwrap(), endpoint.host().unwrap()); 32 | claims = claims.with_audience(audience); 33 | } else { 34 | //Use provided claims if given. This is here to avoid breaking changes. 35 | let aud = claims.custom.get("aud").unwrap().clone(); 36 | //NOTE: This as_str is needed, else \" gets added around the string 37 | claims = claims.with_audience(aud.as_str().ok_or(WebPushError::InvalidClaims)?); 38 | claims.custom.remove("aud"); 39 | } 40 | 41 | //Override the exp claim if provided in custom. Must then remove from custom to avoid printing 42 | //Twice, as this is just for backwards compatibility. 43 | if claims.custom.contains_key("exp") { 44 | let exp = claims.custom.get("exp").unwrap().clone(); 45 | claims.expires_at = Some(Duration::from_secs(exp.as_u64().ok_or(WebPushError::InvalidClaims)?)); 46 | claims.custom.remove("exp"); 47 | } 48 | 49 | // Add sub if not provided as some browsers (like firefox) require it even though the API doesn't say its needed >:[ 50 | if !claims.custom.contains_key("sub") { 51 | claims = claims.with_subject("mailto:example@example.com".to_string()); 52 | } 53 | 54 | log::trace!("Using jwt: {:?}", claims); 55 | 56 | let auth_k = key.public_key(); 57 | 58 | //Generate JWT signature 59 | let auth_t = key.0.sign(claims).map_err(|_| WebPushError::InvalidClaims)?; 60 | 61 | Ok(VapidSignature { auth_t, auth_k }) 62 | } 63 | } 64 | 65 | #[cfg(test)] 66 | mod tests {} 67 | --------------------------------------------------------------------------------