├── .github └── workflows │ └── CI.yml ├── .gitignore ├── LICENSE ├── README.md ├── WebPush.Test ├── ECKeyHelperTest.cs ├── JWSSignerTest.cs ├── UrlBase64Test.cs ├── VapidHelperTest.cs ├── WebPush.Test.csproj └── WebPushClientTest.cs ├── WebPush.sln └── WebPush ├── IWebPushClient.cs ├── Model ├── EncryptionResult.cs ├── InvalidEncryptionDetailsException.cs ├── PushSubscription.cs ├── VapidDetails.cs └── WebPushException.cs ├── Properties └── PublishProfiles │ └── FolderProfile.pubxml ├── Util ├── ECKeyHelper.cs ├── Encryptor.cs ├── JwsSigner.cs └── UrlBase64.cs ├── VapidHelper.cs ├── WebPush.csproj └── WebPushClient.cs /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI Build 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | 17 | - name: Setup .NET SDK 18 | uses: actions/setup-dotnet@v4.0.0 19 | with: 20 | dotnet-version: | 21 | 5.0.x 22 | 6.0.x 23 | 7.0.x 24 | 8.0.x 25 | 26 | - name: Restore 27 | run: dotnet restore 28 | 29 | - name: Build 30 | run: dotnet build --configuration Release --no-restore 31 | 32 | - name: Test 33 | run: | 34 | dotnet test --no-restore --framework net5.0 35 | dotnet test --no-restore --framework net6.0 36 | dotnet test --no-restore --framework net7.0 37 | dotnet test --no-restore --framework net8.0 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | obj 3 | *.xslt.sql 4 | *.user 5 | *.suo 6 | *.orig 7 | .vs 8 | packages 9 | nuget/package/lib 10 | *.nupkg -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

web-push-csharp

2 | 3 |

4 | 5 | CI Build 6 | 7 | 8 | Nuget Package Details 9 | 10 |

11 | 12 | # Why 13 | 14 | Web push requires that push messages triggered from a backend be done via the 15 | [Web Push Protocol](https://tools.ietf.org/html/draft-ietf-webpush-protocol) 16 | and if you want to send data with your push message, you must also encrypt 17 | that data according to the [Message Encryption for Web Push spec](https://tools.ietf.org/html/draft-ietf-webpush-encryption). 18 | 19 | This package makes it easy to send messages and will also handle legacy support 20 | for browsers relying on GCM for message sending / delivery. 21 | 22 | # Install 23 | 24 | Installation is simple, just install via NuGet. 25 | 26 | Install-Package WebPush 27 | 28 | # Demo Project 29 | 30 | There is a ASP.NET MVC Core demo project located [here](https://github.com/coryjthompson/WebPushDemo) 31 | 32 | # Usage 33 | 34 | The common use case for this library is an application server using 35 | a GCM API key and VAPID keys. 36 | 37 | ```csharp 38 | using WebPush; 39 | 40 | var pushEndpoint = @"https://fcm.googleapis.com/fcm/send/efz_TLX_rLU:APA91bE6U0iybLYvv0F3mf6uDLB6...."; 41 | var p256dh = @"BKK18ZjtENC4jdhAAg9OfJacySQiDVcXMamy3SKKy7FwJcI5E0DKO9v4V2Pb8NnAPN4EVdmhO............"; 42 | var auth = @"fkJatBBEl..............."; 43 | 44 | var subject = @"mailto:example@example.com"; 45 | var publicKey = @"BDjASz8kkVBQJgWcD05uX3VxIs_gSHyuS023jnBoHBgUbg8zIJvTSQytR8MP4Z3-kzcGNVnM..............."; 46 | var privateKey = @"mryM-krWj_6IsIMGsd8wNFXGBxnx..............."; 47 | 48 | var subscription = new PushSubscription(pushEndpoint, p256dh, auth); 49 | var vapidDetails = new VapidDetails(subject, publicKey, privateKey); 50 | //var gcmAPIKey = @"[your key here]"; 51 | 52 | var webPushClient = new WebPushClient(); 53 | try 54 | { 55 | await webPushClient.SendNotificationAsync(subscription, "payload", vapidDetails); 56 | //await webPushClient.SendNotificationAsync(subscription, "payload", gcmAPIKey); 57 | } 58 | catch (WebPushException exception) 59 | { 60 | Console.WriteLine("Http STATUS code" + exception.StatusCode); 61 | } 62 | ``` 63 | 64 | # API Reference 65 | 66 | ## SendNotificationAsync(pushSubscription, payload, vapidDetails|gcmAPIKey|options, cancellationToken) 67 | 68 | ```csharp 69 | var subscription = new PushSubscription(pushEndpoint, p256dh, auth); 70 | 71 | var options = new Dictionary(); 72 | options["vapidDetails"] = new VapidDetails(subject, publicKey, privateKey); 73 | //options["gcmAPIKey"] = @"[your key here]"; 74 | 75 | var webPushClient = new WebPushClient(); 76 | try 77 | { 78 | webPushClient.SendNotificationAsync(subscription, "payload", options); 79 | } 80 | catch (WebPushException exception) 81 | { 82 | Console.WriteLine("Http STATUS code" + exception.StatusCode); 83 | } 84 | ``` 85 | 86 | > **Note:** `SendNotificationAsync()` you don't need to define a payload, and this 87 | method will work without a GCM API Key and / or VAPID keys if the push service 88 | supports it. 89 | 90 | ### Input 91 | 92 | **Push Subscription** 93 | 94 | The first argument must be an PushSubscription object containing the details for a push 95 | subscription. 96 | 97 | **Payload** 98 | 99 | The payload is optional, but if set, will be the data sent with a push 100 | message. 101 | 102 | This must be a *string* 103 | > **Note:** In order to encrypt the *payload*, the *pushSubscription* **must** 104 | have a *keys* object with *p256dh* and *auth* values. 105 | 106 | **Options** 107 | 108 | Options is an optional argument that if defined should be an Dictionary containing 109 | any of the following values defined, although none of them are required. 110 | 111 | - **gcmAPIKey** can be a GCM API key to be used for this request and this 112 | request only. This overrides any API key set via `setGCMAPIKey()`. 113 | - **vapidDetails** should be a VapidDetails object with *subject*, *publicKey* and 114 | *privateKey* values defined. These values should follow the [VAPID Spec](https://tools.ietf.org/html/draft-thomson-webpush-vapid). 115 | - **TTL** is a value in seconds that describes how long a push message is 116 | retained by the push service (by default, four weeks). 117 | - **headers** is an object with all the extra headers you want to add to the request. 118 | 119 |
120 | 121 | ## GenerateVapidKeys() 122 | 123 | ```csharp 124 | VapidDetails vapidKeys = VapidHelper.GenerateVapidKeys(); 125 | 126 | // Prints 2 URL Safe Base64 Encoded Strings 127 | Console.WriteLine("Public {0}", vapidKeys.PublicKey); 128 | Console.WriteLine("Private {0}", vapidKeys.PrivateKey); 129 | ``` 130 | 131 | ### Input 132 | 133 | None. 134 | 135 | ### Returns 136 | 137 | Returns a VapidDetails object with **PublicKey** and **PrivateKey** values populated which are 138 | URL Safe Base64 encoded strings. 139 | 140 | > **Note:** You should create these keys once, store them and use them for all 141 | > future messages you send. 142 | 143 |
144 | 145 | ## SetGCMAPIKey(apiKey) 146 | 147 | ```csharp 148 | webPushClient.SetGCMAPIKey(@"your-gcm-key"); 149 | ``` 150 | 151 | ### Input 152 | 153 | This method expects the GCM API key that is linked to the `gcm_sender_id ` in 154 | your web app manifest. 155 | 156 | You can use a GCM API Key from the Google Developer Console or the 157 | *Cloud Messaging* tab under a Firebase Project. 158 | 159 | ### Returns 160 | 161 | None. 162 | 163 |
164 | 165 | ## GetVapidHeaders(audience, subject, publicKey, privateKey, expiration) 166 | 167 | ```csharp 168 | Uri uri = new Uri(subscription.Endpoint); 169 | string audience = uri.Scheme + Uri.SchemeDelimiter + uri.Host; 170 | 171 | Dictionary vapidHeaders = VapidHelper.GetVapidHeaders( 172 | audience, 173 | @"mailto: example@example.com", 174 | publicKey, 175 | privateKey 176 | ); 177 | ``` 178 | 179 | The *GetVapidHeaders()* method will take in the values needed to create 180 | an Authorization and Crypto-Key header. 181 | 182 | ### Input 183 | 184 | The `GetVapidHeaders()` method expects the following input: 185 | 186 | - *audience*: the origin of the **push service**. 187 | - *subject*: the mailto or URL for your application. 188 | - *publicKey*: the VAPID public key. 189 | - *privateKey*: the VAPID private key. 190 | 191 | ### Returns 192 | 193 | This method returns a Dictionary intented to be headers of a web request. It will contain the following keys: 194 | 195 | - *Authorization* 196 | - *Crypto-Key*. 197 | 198 |
199 | 200 | # Browser Support 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 248 | 249 | 254 | 255 | 256 | 257 | 258 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 |
BrowserPush without PayloadPush with PayloadVAPIDNotes
Chrome✓ v42+✓ v50+✓ v52+In v51 and less, the `gcm_sender_id` is needed to get a push subscription.
Firefox✓ v44+✓ v44+✓ v46+
Opera✓ v39+ Android * 244 |
245 |
246 | ✓ v42+ Desktop 247 |
✓ v39+ Android * 250 |
251 |
252 | ✓ v42+ Desktop 253 |
✓ v42+ Desktop 259 | * The `gcm_sender_id` is needed to get a push subscription. 260 |
Edge✓ v17+✓ v17+✓ v17+
Safari
Samsung Internet Browser✓ v4.0.10-53+The `gcm_sender_id` is needed to get a push subscription.
306 | 307 | # Help 308 | 309 | **Service Worker Cookbook** 310 | 311 | The [Service Worker Cookbook](https://serviceworke.rs/) is full of Web Push 312 | examples. 313 | 314 | # Credits 315 | - Ported from https://github.com/web-push-libs/web-push. 316 | - Original Encryption code from https://github.com/LogicSoftware/WebPushEncryption 317 | -------------------------------------------------------------------------------- /WebPush.Test/ECKeyHelperTest.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using Microsoft.VisualStudio.TestTools.UnitTesting; 3 | using Org.BouncyCastle.Crypto.Parameters; 4 | using WebPush.Util; 5 | 6 | namespace WebPush.Test 7 | { 8 | [TestClass] 9 | public class ECKeyHelperTest 10 | { 11 | private const string TestPublicKey = 12 | @"BCvKwB2lbVUYMFAaBUygooKheqcEU-GDrVRnu8k33yJCZkNBNqjZj0VdxQ2QIZa4kV5kpX9aAqyBKZHURm6eG1A"; 13 | 14 | private const string TestPrivateKey = @"on6X5KmLEFIVvPP3cNX9kE0OF6PV9TJQXVbnKU2xEHI"; 15 | 16 | [TestMethod] 17 | public void TestGenerateKeys() 18 | { 19 | var keys = ECKeyHelper.GenerateKeys(); 20 | 21 | var publicKey = ((ECPublicKeyParameters) keys.Public).Q.GetEncoded(false); 22 | var privateKey = ((ECPrivateKeyParameters) keys.Private).D.ToByteArrayUnsigned(); 23 | 24 | var publicKeyLength = publicKey.Length; 25 | var privateKeyLength = privateKey.Length; 26 | 27 | Assert.AreEqual(65, publicKeyLength); 28 | Assert.AreEqual(32, privateKeyLength); 29 | } 30 | 31 | [TestMethod] 32 | public void TestGenerateKeysNoCache() 33 | { 34 | var keys1 = ECKeyHelper.GenerateKeys(); 35 | var keys2 = ECKeyHelper.GenerateKeys(); 36 | 37 | var publicKey1 = ((ECPublicKeyParameters) keys1.Public).Q.GetEncoded(false); 38 | var privateKey1 = ((ECPrivateKeyParameters) keys1.Private).D.ToByteArrayUnsigned(); 39 | 40 | var publicKey2 = ((ECPublicKeyParameters) keys2.Public).Q.GetEncoded(false); 41 | var privateKey2 = ((ECPrivateKeyParameters) keys2.Private).D.ToByteArrayUnsigned(); 42 | 43 | Assert.IsFalse(publicKey1.SequenceEqual(publicKey2)); 44 | Assert.IsFalse(privateKey1.SequenceEqual(privateKey2)); 45 | } 46 | 47 | [TestMethod] 48 | public void TestGetPrivateKey() 49 | { 50 | var privateKey = UrlBase64.Decode(TestPrivateKey); 51 | var privateKeyParams = ECKeyHelper.GetPrivateKey(privateKey); 52 | 53 | var importedPrivateKey = UrlBase64.Encode(privateKeyParams.D.ToByteArrayUnsigned()); 54 | 55 | Assert.AreEqual(TestPrivateKey, importedPrivateKey); 56 | } 57 | 58 | [TestMethod] 59 | public void TestGetPublicKey() 60 | { 61 | var publicKey = UrlBase64.Decode(TestPublicKey); 62 | var publicKeyParams = ECKeyHelper.GetPublicKey(publicKey); 63 | 64 | var importedPublicKey = UrlBase64.Encode(publicKeyParams.Q.GetEncoded(false)); 65 | 66 | Assert.AreEqual(TestPublicKey, importedPublicKey); 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /WebPush.Test/JWSSignerTest.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text; 3 | using Microsoft.VisualStudio.TestTools.UnitTesting; 4 | using WebPush.Util; 5 | 6 | namespace WebPush.Test 7 | { 8 | [TestClass] 9 | public class JWSSignerTest 10 | { 11 | private const string TestPrivateKey = @"on6X5KmLEFIVvPP3cNX9kE0OF6PV9TJQXVbnKU2xEHI"; 12 | 13 | [TestMethod] 14 | public void TestGenerateSignature() 15 | { 16 | var decodedPrivateKey = UrlBase64.Decode(TestPrivateKey); 17 | var privateKey = ECKeyHelper.GetPrivateKey(decodedPrivateKey); 18 | 19 | var header = new Dictionary(); 20 | header.Add("typ", "JWT"); 21 | header.Add("alg", "ES256"); 22 | 23 | var jwtPayload = new Dictionary(); 24 | jwtPayload.Add("aud", "aud"); 25 | jwtPayload.Add("exp", 1); 26 | jwtPayload.Add("sub", "subject"); 27 | 28 | var signer = new JwsSigner(privateKey); 29 | var token = signer.GenerateSignature(header, jwtPayload); 30 | 31 | var tokenParts = token.Split('.'); 32 | 33 | Assert.AreEqual(3, tokenParts.Length); 34 | 35 | var encodedHeader = tokenParts[0]; 36 | var encodedPayload = tokenParts[1]; 37 | var signature = tokenParts[2]; 38 | 39 | var decodedHeader = Encoding.UTF8.GetString(UrlBase64.Decode(encodedHeader)); 40 | var decodedPayload = Encoding.UTF8.GetString(UrlBase64.Decode(encodedPayload)); 41 | 42 | Assert.AreEqual(@"{""typ"":""JWT"",""alg"":""ES256""}", decodedHeader); 43 | Assert.AreEqual(@"{""aud"":""aud"",""exp"":1,""sub"":""subject""}", decodedPayload); 44 | 45 | var decodedSignature = UrlBase64.Decode(signature); 46 | var decodedSignatureLength = decodedSignature.Length; 47 | 48 | var isSignatureLengthValid = decodedSignatureLength == 66 || decodedSignatureLength == 64; 49 | Assert.AreEqual(true, isSignatureLengthValid); 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /WebPush.Test/UrlBase64Test.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using Microsoft.VisualStudio.TestTools.UnitTesting; 3 | using WebPush.Util; 4 | 5 | namespace WebPush.Test 6 | { 7 | [TestClass] 8 | public class UrlBase64Test 9 | { 10 | [TestMethod] 11 | public void TestBase64UrlDecode() 12 | { 13 | var expected = new byte[3] {181, 235, 45}; 14 | var actual = UrlBase64.Decode(@"test"); 15 | Assert.IsTrue(actual.SequenceEqual(expected)); 16 | } 17 | 18 | [TestMethod] 19 | public void TestBase64UrlEncode() 20 | { 21 | var expected = @"test"; 22 | var actual = UrlBase64.Encode(new byte[3] {181, 235, 45}); 23 | Assert.AreEqual(expected, actual); 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /WebPush.Test/VapidHelperTest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.VisualStudio.TestTools.UnitTesting; 3 | using WebPush.Util; 4 | 5 | namespace WebPush.Test 6 | { 7 | [TestClass] 8 | public class VapidHelperTest 9 | { 10 | private const string ValidAudience = @"http://example.com"; 11 | private const string ValidSubject = @"http://example.com/example"; 12 | private const string ValidSubjectMailto = @"mailto:example@example.com"; 13 | 14 | private const string TestPublicKey = 15 | @"BCvKwB2lbVUYMFAaBUygooKheqcEU-GDrVRnu8k33yJCZkNBNqjZj0VdxQ2QIZa4kV5kpX9aAqyBKZHURm6eG1A"; 16 | 17 | private const string TestPrivateKey = @"on6X5KmLEFIVvPP3cNX9kE0OF6PV9TJQXVbnKU2xEHI"; 18 | 19 | [TestMethod] 20 | public void TestGenerateVapidKeys() 21 | { 22 | var keys = VapidHelper.GenerateVapidKeys(); 23 | var publicKey = UrlBase64.Decode(keys.PublicKey); 24 | var privateKey = UrlBase64.Decode(keys.PrivateKey); 25 | 26 | Assert.AreEqual(32, privateKey.Length); 27 | Assert.AreEqual(65, publicKey.Length); 28 | } 29 | 30 | [TestMethod] 31 | public void TestGenerateVapidKeysNoCache() 32 | { 33 | var keys1 = VapidHelper.GenerateVapidKeys(); 34 | var keys2 = VapidHelper.GenerateVapidKeys(); 35 | 36 | Assert.AreNotEqual(keys1.PublicKey, keys2.PublicKey); 37 | Assert.AreNotEqual(keys1.PrivateKey, keys2.PrivateKey); 38 | } 39 | 40 | [TestMethod] 41 | public void TestGetVapidHeaders() 42 | { 43 | var publicKey = TestPublicKey; 44 | var privateKey = TestPrivateKey; 45 | var headers = VapidHelper.GetVapidHeaders(ValidAudience, ValidSubject, publicKey, privateKey); 46 | 47 | Assert.IsTrue(headers.ContainsKey(@"Authorization")); 48 | Assert.IsTrue(headers.ContainsKey(@"Crypto-Key")); 49 | } 50 | 51 | [TestMethod] 52 | public void TestGetVapidHeadersAudienceNotAUrl() 53 | { 54 | var publicKey = TestPublicKey; 55 | var privateKey = TestPrivateKey; 56 | Assert.ThrowsException( 57 | delegate 58 | { 59 | VapidHelper.GetVapidHeaders("invalid audience", ValidSubjectMailto, publicKey, privateKey); 60 | }); 61 | } 62 | 63 | [TestMethod] 64 | public void TestGetVapidHeadersInvalidPrivateKey() 65 | { 66 | var publicKey = UrlBase64.Encode(new byte[65]); 67 | var privateKey = UrlBase64.Encode(new byte[1]); 68 | 69 | Assert.ThrowsException( 70 | delegate { VapidHelper.GetVapidHeaders(ValidAudience, ValidSubject, publicKey, privateKey); }); 71 | } 72 | 73 | [TestMethod] 74 | public void TestGetVapidHeadersInvalidPublicKey() 75 | { 76 | var publicKey = UrlBase64.Encode(new byte[1]); 77 | var privateKey = UrlBase64.Encode(new byte[32]); 78 | 79 | Assert.ThrowsException( 80 | delegate { VapidHelper.GetVapidHeaders(ValidAudience, ValidSubject, publicKey, privateKey); }); 81 | } 82 | 83 | [TestMethod] 84 | public void TestGetVapidHeadersSubjectNotAUrlOrMailTo() 85 | { 86 | var publicKey = TestPublicKey; 87 | var privateKey = TestPrivateKey; 88 | 89 | Assert.ThrowsException( 90 | delegate { VapidHelper.GetVapidHeaders(ValidAudience, @"invalid subject", publicKey, privateKey); }); 91 | } 92 | 93 | [TestMethod] 94 | public void TestGetVapidHeadersWithMailToSubject() 95 | { 96 | var publicKey = TestPublicKey; 97 | var privateKey = TestPrivateKey; 98 | var headers = VapidHelper.GetVapidHeaders(ValidAudience, ValidSubjectMailto, publicKey, 99 | privateKey); 100 | 101 | Assert.IsTrue(headers.ContainsKey(@"Authorization")); 102 | Assert.IsTrue(headers.ContainsKey(@"Crypto-Key")); 103 | } 104 | 105 | [TestMethod] 106 | public void TestExpirationInPastExceptions() 107 | { 108 | var publicKey = TestPublicKey; 109 | var privateKey = TestPrivateKey; 110 | 111 | Assert.ThrowsException( 112 | delegate 113 | { 114 | VapidHelper.GetVapidHeaders(ValidAudience, ValidSubjectMailto, publicKey, 115 | privateKey, 1552715607); 116 | }); 117 | } 118 | } 119 | } -------------------------------------------------------------------------------- /WebPush.Test/WebPush.Test.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net462;net471;net48;netcoreapp3.1;net5.0;net6.0;net7.0;net8.0 5 | false 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /WebPush.Test/WebPushClientTest.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.VisualStudio.TestTools.UnitTesting; 2 | using RichardSzalay.MockHttp; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Net; 7 | using System.Net.Http; 8 | using WebPush.Model; 9 | 10 | namespace WebPush.Test 11 | { 12 | [TestClass] 13 | public class WebPushClientTest 14 | { 15 | private const string TestPublicKey = 16 | @"BCvKwB2lbVUYMFAaBUygooKheqcEU-GDrVRnu8k33yJCZkNBNqjZj0VdxQ2QIZa4kV5kpX9aAqyBKZHURm6eG1A"; 17 | 18 | private const string TestPrivateKey = @"on6X5KmLEFIVvPP3cNX9kE0OF6PV9TJQXVbnKU2xEHI"; 19 | 20 | private const string TestGcmEndpoint = @"https://android.googleapis.com/gcm/send/"; 21 | 22 | private const string TestFcmEndpoint = 23 | @"https://fcm.googleapis.com/fcm/send/efz_TLX_rLU:APA91bE6U0iybLYvv0F3mf6"; 24 | 25 | private const string TestFirefoxEndpoint = 26 | @"https://updates.push.services.mozilla.com/wpush/v2/gBABAABgOe_sGrdrsT35ljtA4O9xCX"; 27 | 28 | public const string TestSubject = "mailto:example@example.com"; 29 | 30 | private MockHttpMessageHandler httpMessageHandlerMock; 31 | private WebPushClient client; 32 | 33 | [TestInitialize] 34 | public void InitializeTest() 35 | { 36 | httpMessageHandlerMock = new MockHttpMessageHandler(); 37 | client = new WebPushClient(httpMessageHandlerMock.ToHttpClient()); 38 | } 39 | 40 | [TestMethod] 41 | public void TestGcmApiKeyInOptions() 42 | { 43 | var gcmAPIKey = @"teststring"; 44 | var subscription = new PushSubscription(TestGcmEndpoint, TestPublicKey, TestPrivateKey); 45 | 46 | var options = new Dictionary(); 47 | options[@"gcmAPIKey"] = gcmAPIKey; 48 | var message = client.GenerateRequestDetails(subscription, @"test payload", options); 49 | var authorizationHeader = message.Headers.GetValues(@"Authorization").First(); 50 | 51 | Assert.AreEqual("key=" + gcmAPIKey, authorizationHeader); 52 | 53 | // Test previous incorrect casing of gcmAPIKey 54 | var options2 = new Dictionary(); 55 | options2[@"gcmApiKey"] = gcmAPIKey; 56 | Assert.ThrowsException(delegate 57 | { 58 | client.GenerateRequestDetails(subscription, "test payload", options2); 59 | }); 60 | } 61 | 62 | [TestMethod] 63 | public void TestSetGcmApiKey() 64 | { 65 | var gcmAPIKey = @"teststring"; 66 | client.SetGcmApiKey(gcmAPIKey); 67 | var subscription = new PushSubscription(TestGcmEndpoint, TestPublicKey, TestPrivateKey); 68 | var message = client.GenerateRequestDetails(subscription, @"test payload"); 69 | var authorizationHeader = message.Headers.GetValues(@"Authorization").First(); 70 | 71 | Assert.AreEqual(@"key=" + gcmAPIKey, authorizationHeader); 72 | } 73 | 74 | [TestMethod] 75 | public void TestSetGCMAPIKeyEmptyString() 76 | { 77 | Assert.ThrowsException(delegate 78 | { client.SetGcmApiKey(""); }); 79 | } 80 | 81 | [TestMethod] 82 | public void TestSetGcmApiKeyNonGcmPushService() 83 | { 84 | // Ensure that the API key doesn't get added on a service that doesn't accept it. 85 | var gcmAPIKey = @"teststring"; 86 | client.SetGcmApiKey(gcmAPIKey); 87 | var subscription = new PushSubscription(TestFirefoxEndpoint, TestPublicKey, TestPrivateKey); 88 | var message = client.GenerateRequestDetails(subscription, @"test payload"); 89 | 90 | Assert.IsFalse(message.Headers.TryGetValues(@"Authorization", out var values)); 91 | } 92 | 93 | [TestMethod] 94 | public void TestSetGcmApiKeyNull() 95 | { 96 | client.SetGcmApiKey(@"somestring"); 97 | client.SetGcmApiKey(null); 98 | 99 | var subscription = new PushSubscription(TestGcmEndpoint, TestPublicKey, TestPrivateKey); 100 | var message = client.GenerateRequestDetails(subscription, @"test payload"); 101 | 102 | Assert.IsFalse(message.Headers.TryGetValues("Authorization", out var values)); 103 | } 104 | 105 | [TestMethod] 106 | public void TestSetVapidDetails() 107 | { 108 | client.SetVapidDetails(TestSubject, TestPublicKey, TestPrivateKey); 109 | 110 | var subscription = new PushSubscription(TestFirefoxEndpoint, TestPublicKey, TestPrivateKey); 111 | var message = client.GenerateRequestDetails(subscription, @"test payload"); 112 | var authorizationHeader = message.Headers.GetValues(@"Authorization").First(); 113 | var cryptoHeader = message.Headers.GetValues(@"Crypto-Key").First(); 114 | 115 | Assert.IsTrue(authorizationHeader.StartsWith(@"WebPush ")); 116 | Assert.IsTrue(cryptoHeader.Contains(@"p256ecdsa")); 117 | } 118 | 119 | [TestMethod] 120 | public void TestFcmAddsAuthorizationHeader() 121 | { 122 | client.SetGcmApiKey(@"somestring"); 123 | var subscription = new PushSubscription(TestFcmEndpoint, TestPublicKey, TestPrivateKey); 124 | var message = client.GenerateRequestDetails(subscription, @"test payload"); 125 | var authorizationHeader = message.Headers.GetValues(@"Authorization").First(); 126 | 127 | Assert.IsTrue(authorizationHeader.StartsWith(@"key=")); 128 | } 129 | 130 | [TestMethod] 131 | [DataRow(HttpStatusCode.Created)] 132 | [DataRow(HttpStatusCode.Accepted)] 133 | public void TestHandlingSuccessHttpCodes(HttpStatusCode status) 134 | { 135 | TestSendNotification(status); 136 | } 137 | 138 | [TestMethod] 139 | [DataRow(HttpStatusCode.BadRequest, "Bad Request")] 140 | [DataRow(HttpStatusCode.RequestEntityTooLarge, "Payload too large")] 141 | [DataRow((HttpStatusCode)429, "Too many request")] 142 | [DataRow(HttpStatusCode.NotFound, "Subscription no longer valid")] 143 | [DataRow(HttpStatusCode.Gone, "Subscription no longer valid")] 144 | [DataRow(HttpStatusCode.InternalServerError, "Received unexpected response code: 500")] 145 | public void TestHandlingFailureHttpCodes(HttpStatusCode status, string expectedMessage) 146 | { 147 | var actual = Assert.ThrowsException(() => TestSendNotification(status)); 148 | Assert.AreEqual(expectedMessage, actual.Message); 149 | } 150 | 151 | [TestMethod] 152 | [DataRow(HttpStatusCode.BadRequest, "authorization key missing", "Bad Request. Details: authorization key missing")] 153 | [DataRow(HttpStatusCode.RequestEntityTooLarge, "max size is 512", "Payload too large. Details: max size is 512")] 154 | [DataRow((HttpStatusCode)429, "the api is limited", "Too many request. Details: the api is limited")] 155 | [DataRow(HttpStatusCode.NotFound, "", "Subscription no longer valid")] 156 | [DataRow(HttpStatusCode.Gone, "", "Subscription no longer valid")] 157 | [DataRow(HttpStatusCode.InternalServerError, "internal error", "Received unexpected response code: 500. Details: internal error")] 158 | public void TestHandlingFailureMessages(HttpStatusCode status, string response, string expectedMessage) 159 | { 160 | var actual = Assert.ThrowsException(() => TestSendNotification(status, response)); 161 | Assert.AreEqual(expectedMessage, actual.Message); 162 | } 163 | 164 | [TestMethod] 165 | [DataRow(1)] 166 | [DataRow(5)] 167 | [DataRow(10)] 168 | [DataRow(50)] 169 | public void TestHandleInvalidPublicKeys(int charactersToDrop) 170 | { 171 | var invalidKey = TestPublicKey.Substring(0, TestPublicKey.Length - charactersToDrop); 172 | 173 | Assert.ThrowsException(() => TestSendNotification(HttpStatusCode.OK, response: null, invalidKey)); 174 | } 175 | 176 | private void TestSendNotification(HttpStatusCode status, string response = null, string publicKey = TestPublicKey) 177 | { 178 | var subscription = new PushSubscription(TestFcmEndpoint, publicKey, TestPrivateKey); 179 | var httpContent = response == null ? null : new StringContent(response); 180 | httpMessageHandlerMock.When(TestFcmEndpoint).Respond(req => new HttpResponseMessage { StatusCode = status, Content = httpContent }); 181 | client.SetVapidDetails(TestSubject, TestPublicKey, TestPrivateKey); 182 | client.SendNotification(subscription, "123"); 183 | } 184 | } 185 | } -------------------------------------------------------------------------------- /WebPush.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.27130.2020 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebPush", "WebPush\WebPush.csproj", "{2003DA6A-AB53-4B2D-9ECC-846B7E3E3D5E}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebPush.Test", "WebPush.Test\WebPush.Test.csproj", "{1CA4B246-C3C1-43F0-818C-8CBC994E1225}" 9 | EndProject 10 | Global 11 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 12 | Debug|Any CPU = Debug|Any CPU 13 | Release|Any CPU = Release|Any CPU 14 | EndGlobalSection 15 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 16 | {2003DA6A-AB53-4B2D-9ECC-846B7E3E3D5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 17 | {2003DA6A-AB53-4B2D-9ECC-846B7E3E3D5E}.Debug|Any CPU.Build.0 = Debug|Any CPU 18 | {2003DA6A-AB53-4B2D-9ECC-846B7E3E3D5E}.Release|Any CPU.ActiveCfg = Release|Any CPU 19 | {2003DA6A-AB53-4B2D-9ECC-846B7E3E3D5E}.Release|Any CPU.Build.0 = Release|Any CPU 20 | {1CA4B246-C3C1-43F0-818C-8CBC994E1225}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 21 | {1CA4B246-C3C1-43F0-818C-8CBC994E1225}.Debug|Any CPU.Build.0 = Debug|Any CPU 22 | {1CA4B246-C3C1-43F0-818C-8CBC994E1225}.Release|Any CPU.ActiveCfg = Release|Any CPU 23 | {1CA4B246-C3C1-43F0-818C-8CBC994E1225}.Release|Any CPU.Build.0 = Release|Any CPU 24 | EndGlobalSection 25 | GlobalSection(SolutionProperties) = preSolution 26 | HideSolutionNode = FALSE 27 | EndGlobalSection 28 | GlobalSection(ExtensibilityGlobals) = postSolution 29 | SolutionGuid = {2524F24D-BE69-493E-9563-448AAD5EDE30} 30 | EndGlobalSection 31 | EndGlobal 32 | -------------------------------------------------------------------------------- /WebPush/IWebPushClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Net.Http; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | namespace WebPush 8 | { 9 | public interface IWebPushClient : IDisposable 10 | { 11 | /// 12 | /// When sending messages to a GCM endpoint you need to set the GCM API key 13 | /// by either calling setGcmApiKey() or passing in the API key as an option 14 | /// to sendNotification() 15 | /// 16 | /// The API key to send with the GCM request. 17 | void SetGcmApiKey(string gcmApiKey); 18 | 19 | /// 20 | /// When marking requests where you want to define VAPID details, call this method 21 | /// before sendNotifications() or pass in the details and options to 22 | /// sendNotification. 23 | /// 24 | /// 25 | void SetVapidDetails(VapidDetails vapidDetails); 26 | 27 | /// 28 | /// When marking requests where you want to define VAPID details, call this method 29 | /// before sendNotifications() or pass in the details and options to 30 | /// sendNotification. 31 | /// 32 | /// This must be either a URL or a 'mailto:' address 33 | /// The public VAPID key as a base64 encoded string 34 | /// The private VAPID key as a base64 encoded string 35 | void SetVapidDetails(string subject, string publicKey, string privateKey); 36 | 37 | /// 38 | /// To get a request without sending a push notification call this method. 39 | /// This method will throw an ArgumentException if there is an issue with the input. 40 | /// 41 | /// The PushSubscription you wish to send the notification to. 42 | /// The payload you wish to send to the user 43 | /// 44 | /// Options for the GCM API key and vapid keys can be passed in if they are unique for each 45 | /// notification. 46 | /// 47 | /// A HttpRequestMessage object that can be sent. 48 | HttpRequestMessage GenerateRequestDetails(PushSubscription subscription, string payload, 49 | Dictionary options = null); 50 | 51 | /// 52 | /// To send a push notification call this method with a subscription, optional payload and any options 53 | /// Will exception if unsuccessful 54 | /// 55 | /// The PushSubscription you wish to send the notification to. 56 | /// The payload you wish to send to the user 57 | /// 58 | /// Options for the GCM API key and vapid keys can be passed in if they are unique for each 59 | /// notification. 60 | /// 61 | void SendNotification(PushSubscription subscription, string payload = null, 62 | Dictionary options = null); 63 | 64 | /// 65 | /// To send a push notification call this method with a subscription, optional payload and any options 66 | /// Will exception if unsuccessful 67 | /// 68 | /// The PushSubscription you wish to send the notification to. 69 | /// The payload you wish to send to the user 70 | /// The vapid details for the notification. 71 | void SendNotification(PushSubscription subscription, string payload, VapidDetails vapidDetails); 72 | 73 | /// 74 | /// To send a push notification call this method with a subscription, optional payload and any options 75 | /// Will exception if unsuccessful 76 | /// 77 | /// The PushSubscription you wish to send the notification to. 78 | /// The payload you wish to send to the user 79 | /// The GCM API key 80 | void SendNotification(PushSubscription subscription, string payload, string gcmApiKey); 81 | 82 | /// 83 | /// To send a push notification asynchronous call this method with a subscription, optional payload and any options 84 | /// Will exception if unsuccessful 85 | /// 86 | /// The PushSubscription you wish to send the notification to. 87 | /// The payload you wish to send to the user 88 | /// 89 | /// Options for the GCM API key and vapid keys can be passed in if they are unique for each 90 | /// notification. 91 | /// 92 | /// The cancellation token to cancel operation. 93 | Task SendNotificationAsync(PushSubscription subscription, string payload = null, 94 | Dictionary options = null, CancellationToken cancellationToken=default); 95 | 96 | /// 97 | /// To send a push notification asynchronous call this method with a subscription, optional payload and any options 98 | /// Will exception if unsuccessful 99 | /// 100 | /// The PushSubscription you wish to send the notification to. 101 | /// The payload you wish to send to the user 102 | /// The vapid details for the notification. 103 | /// 104 | Task SendNotificationAsync(PushSubscription subscription, string payload, 105 | VapidDetails vapidDetails, CancellationToken cancellationToken=default); 106 | 107 | /// 108 | /// To send a push notification asynchronous call this method with a subscription, optional payload and any options 109 | /// Will exception if unsuccessful 110 | /// 111 | /// The PushSubscription you wish to send the notification to. 112 | /// The payload you wish to send to the user 113 | /// The GCM API key 114 | /// 115 | Task SendNotificationAsync(PushSubscription subscription, string payload, string gcmApiKey, CancellationToken cancellationToken=default); 116 | } 117 | } -------------------------------------------------------------------------------- /WebPush/Model/EncryptionResult.cs: -------------------------------------------------------------------------------- 1 | using WebPush.Util; 2 | 3 | namespace WebPush 4 | { 5 | // @LogicSoftware 6 | // Originally From: https://github.com/LogicSoftware/WebPushEncryption/blob/master/src/EncryptionResult.cs 7 | public class EncryptionResult 8 | { 9 | public byte[] PublicKey { get; set; } 10 | public byte[] Payload { get; set; } 11 | public byte[] Salt { get; set; } 12 | 13 | public string Base64EncodePublicKey() 14 | { 15 | return UrlBase64.Encode(PublicKey); 16 | } 17 | 18 | public string Base64EncodeSalt() 19 | { 20 | return UrlBase64.Encode(Salt); 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /WebPush/Model/InvalidEncryptionDetailsException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace WebPush.Model 4 | { 5 | public class InvalidEncryptionDetailsException : Exception 6 | { 7 | public InvalidEncryptionDetailsException(string message, PushSubscription pushSubscription) 8 | : base(message) 9 | { 10 | PushSubscription = pushSubscription; 11 | } 12 | 13 | public PushSubscription PushSubscription { get; } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /WebPush/Model/PushSubscription.cs: -------------------------------------------------------------------------------- 1 | namespace WebPush 2 | { 3 | public class PushSubscription 4 | { 5 | public PushSubscription() 6 | { 7 | } 8 | 9 | public PushSubscription(string endpoint, string p256dh, string auth) 10 | { 11 | Endpoint = endpoint; 12 | P256DH = p256dh; 13 | Auth = auth; 14 | } 15 | 16 | public string Endpoint { get; set; } 17 | public string P256DH { get; set; } 18 | public string Auth { get; set; } 19 | } 20 | } -------------------------------------------------------------------------------- /WebPush/Model/VapidDetails.cs: -------------------------------------------------------------------------------- 1 | namespace WebPush 2 | { 3 | public class VapidDetails 4 | { 5 | public VapidDetails() 6 | { 7 | } 8 | 9 | /// This should be a URL or a 'mailto:' email address 10 | /// The VAPID public key as a base64 encoded string 11 | /// The VAPID private key as a base64 encoded string 12 | public VapidDetails(string subject, string publicKey, string privateKey) 13 | { 14 | Subject = subject; 15 | PublicKey = publicKey; 16 | PrivateKey = privateKey; 17 | } 18 | 19 | public string Subject { get; set; } 20 | public string PublicKey { get; set; } 21 | public string PrivateKey { get; set; } 22 | 23 | public long Expiration { get; set; } = -1; 24 | } 25 | } -------------------------------------------------------------------------------- /WebPush/Model/WebPushException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net; 3 | using System.Net.Http; 4 | using System.Net.Http.Headers; 5 | 6 | namespace WebPush 7 | { 8 | public class WebPushException : Exception 9 | { 10 | public WebPushException(string message, PushSubscription pushSubscription, HttpResponseMessage responseMessage) : base(message) 11 | { 12 | PushSubscription = pushSubscription; 13 | HttpResponseMessage = responseMessage; 14 | } 15 | 16 | public HttpStatusCode StatusCode => HttpResponseMessage.StatusCode; 17 | 18 | public HttpResponseHeaders Headers => HttpResponseMessage.Headers; 19 | public PushSubscription PushSubscription { get; set; } 20 | public HttpResponseMessage HttpResponseMessage { get; set; } 21 | } 22 | } -------------------------------------------------------------------------------- /WebPush/Properties/PublishProfiles/FolderProfile.pubxml: -------------------------------------------------------------------------------- 1 |  2 | 6 | 7 | 8 | FileSystem 9 | Release 10 | netstandard1.1 11 | bin\Release\PublishOutput 12 | 13 | -------------------------------------------------------------------------------- /WebPush/Util/ECKeyHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using Org.BouncyCastle.Asn1; 4 | using Org.BouncyCastle.Asn1.Nist; 5 | using Org.BouncyCastle.Crypto; 6 | using Org.BouncyCastle.Crypto.Parameters; 7 | using Org.BouncyCastle.OpenSsl; 8 | using Org.BouncyCastle.Security; 9 | 10 | namespace WebPush.Util 11 | { 12 | internal static class ECKeyHelper 13 | { 14 | public static ECPrivateKeyParameters GetPrivateKey(byte[] privateKey) 15 | { 16 | Asn1Object version = new DerInteger(1); 17 | Asn1Object derEncodedKey = new DerOctetString(privateKey); 18 | Asn1Object keyTypeParameters = new DerTaggedObject(0, new DerObjectIdentifier(@"1.2.840.10045.3.1.7")); 19 | 20 | Asn1Object derSequence = new DerSequence(version, derEncodedKey, keyTypeParameters); 21 | 22 | var base64EncodedDerSequence = Convert.ToBase64String(derSequence.GetDerEncoded()); 23 | var pemKey = "-----BEGIN EC PRIVATE KEY-----\n"; 24 | pemKey += base64EncodedDerSequence; 25 | pemKey += "\n-----END EC PRIVATE KEY----"; 26 | 27 | var reader = new StringReader(pemKey); 28 | var pemReader = new PemReader(reader); 29 | var keyPair = (AsymmetricCipherKeyPair) pemReader.ReadObject(); 30 | 31 | return (ECPrivateKeyParameters) keyPair.Private; 32 | } 33 | 34 | public static ECPublicKeyParameters GetPublicKey(byte[] publicKey) 35 | { 36 | Asn1Object keyTypeParameters = new DerSequence(new DerObjectIdentifier(@"1.2.840.10045.2.1"), 37 | new DerObjectIdentifier(@"1.2.840.10045.3.1.7")); 38 | Asn1Object derEncodedKey = new DerBitString(publicKey); 39 | 40 | Asn1Object derSequence = new DerSequence(keyTypeParameters, derEncodedKey); 41 | 42 | var base64EncodedDerSequence = Convert.ToBase64String(derSequence.GetDerEncoded()); 43 | var pemKey = "-----BEGIN PUBLIC KEY-----\n"; 44 | pemKey += base64EncodedDerSequence; 45 | pemKey += "\n-----END PUBLIC KEY-----"; 46 | 47 | var reader = new StringReader(pemKey); 48 | var pemReader = new PemReader(reader); 49 | var keyPair = pemReader.ReadObject(); 50 | return (ECPublicKeyParameters) keyPair; 51 | } 52 | 53 | public static AsymmetricCipherKeyPair GenerateKeys() 54 | { 55 | var ecParameters = NistNamedCurves.GetByName("P-256"); 56 | var ecSpec = new ECDomainParameters(ecParameters.Curve, ecParameters.G, ecParameters.N, ecParameters.H, 57 | ecParameters.GetSeed()); 58 | var keyPairGenerator = GeneratorUtilities.GetKeyPairGenerator("ECDH"); 59 | keyPairGenerator.Init(new ECKeyGenerationParameters(ecSpec, new SecureRandom())); 60 | 61 | return keyPairGenerator.GenerateKeyPair(); 62 | } 63 | } 64 | } -------------------------------------------------------------------------------- /WebPush/Util/Encryptor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using Org.BouncyCastle.Crypto.Digests; 6 | using Org.BouncyCastle.Crypto.Engines; 7 | using Org.BouncyCastle.Crypto.Macs; 8 | using Org.BouncyCastle.Crypto.Modes; 9 | using Org.BouncyCastle.Crypto.Parameters; 10 | using Org.BouncyCastle.Security; 11 | 12 | namespace WebPush.Util 13 | { 14 | // @LogicSoftware 15 | // Originally from https://github.com/LogicSoftware/WebPushEncryption/blob/master/src/Encryptor.cs 16 | internal static class Encryptor 17 | { 18 | public static EncryptionResult Encrypt(string userKey, string userSecret, string payload) 19 | { 20 | var userKeyBytes = UrlBase64.Decode(userKey); 21 | var userSecretBytes = UrlBase64.Decode(userSecret); 22 | var payloadBytes = Encoding.UTF8.GetBytes(payload); 23 | 24 | return Encrypt(userKeyBytes, userSecretBytes, payloadBytes); 25 | } 26 | 27 | public static EncryptionResult Encrypt(byte[] userKey, byte[] userSecret, byte[] payload) 28 | { 29 | var salt = GenerateSalt(16); 30 | var serverKeyPair = ECKeyHelper.GenerateKeys(); 31 | 32 | var ecdhAgreement = AgreementUtilities.GetBasicAgreement("ECDH"); 33 | ecdhAgreement.Init(serverKeyPair.Private); 34 | 35 | var userPublicKey = ECKeyHelper.GetPublicKey(userKey); 36 | 37 | var key = ecdhAgreement.CalculateAgreement(userPublicKey).ToByteArrayUnsigned(); 38 | var serverPublicKey = ((ECPublicKeyParameters) serverKeyPair.Public).Q.GetEncoded(false); 39 | 40 | var prk = HKDF(userSecret, key, Encoding.UTF8.GetBytes("Content-Encoding: auth\0"), 32); 41 | var cek = HKDF(salt, prk, CreateInfoChunk("aesgcm", userKey, serverPublicKey), 16); 42 | var nonce = HKDF(salt, prk, CreateInfoChunk("nonce", userKey, serverPublicKey), 12); 43 | 44 | var input = AddPaddingToInput(payload); 45 | var encryptedMessage = EncryptAes(nonce, cek, input); 46 | 47 | return new EncryptionResult 48 | { 49 | Salt = salt, 50 | Payload = encryptedMessage, 51 | PublicKey = serverPublicKey 52 | }; 53 | } 54 | 55 | private static byte[] GenerateSalt(int length) 56 | { 57 | var salt = new byte[length]; 58 | var random = new Random(); 59 | random.NextBytes(salt); 60 | return salt; 61 | } 62 | 63 | private static byte[] AddPaddingToInput(byte[] data) 64 | { 65 | var input = new byte[0 + 2 + data.Length]; 66 | Buffer.BlockCopy(ConvertInt(0), 0, input, 0, 2); 67 | Buffer.BlockCopy(data, 0, input, 0 + 2, data.Length); 68 | return input; 69 | } 70 | 71 | private static byte[] EncryptAes(byte[] nonce, byte[] cek, byte[] message) 72 | { 73 | var cipher = new GcmBlockCipher(new AesEngine()); 74 | var parameters = new AeadParameters(new KeyParameter(cek), 128, nonce); 75 | cipher.Init(true, parameters); 76 | 77 | //Generate Cipher Text With Auth Tag 78 | var cipherText = new byte[cipher.GetOutputSize(message.Length)]; 79 | var len = cipher.ProcessBytes(message, 0, message.Length, cipherText, 0); 80 | cipher.DoFinal(cipherText, len); 81 | 82 | //byte[] tag = cipher.GetMac(); 83 | return cipherText; 84 | } 85 | 86 | public static byte[] HKDFSecondStep(byte[] key, byte[] info, int length) 87 | { 88 | var hmac = new HmacSha256(key); 89 | var infoAndOne = info.Concat(new byte[] {0x01}).ToArray(); 90 | var result = hmac.ComputeHash(infoAndOne); 91 | 92 | if (result.Length > length) 93 | { 94 | Array.Resize(ref result, length); 95 | } 96 | 97 | return result; 98 | } 99 | 100 | public static byte[] HKDF(byte[] salt, byte[] prk, byte[] info, int length) 101 | { 102 | var hmac = new HmacSha256(salt); 103 | var key = hmac.ComputeHash(prk); 104 | 105 | return HKDFSecondStep(key, info, length); 106 | } 107 | 108 | public static byte[] ConvertInt(int number) 109 | { 110 | var output = BitConverter.GetBytes(Convert.ToUInt16(number)); 111 | if (BitConverter.IsLittleEndian) 112 | { 113 | Array.Reverse(output); 114 | } 115 | 116 | return output; 117 | } 118 | 119 | public static byte[] CreateInfoChunk(string type, byte[] recipientPublicKey, byte[] senderPublicKey) 120 | { 121 | var output = new List(); 122 | output.AddRange(Encoding.UTF8.GetBytes($"Content-Encoding: {type}\0P-256\0")); 123 | output.AddRange(ConvertInt(recipientPublicKey.Length)); 124 | output.AddRange(recipientPublicKey); 125 | output.AddRange(ConvertInt(senderPublicKey.Length)); 126 | output.AddRange(senderPublicKey); 127 | return output.ToArray(); 128 | } 129 | } 130 | 131 | public class HmacSha256 132 | { 133 | private readonly HMac _hmac; 134 | 135 | public HmacSha256(byte[] key) 136 | { 137 | _hmac = new HMac(new Sha256Digest()); 138 | _hmac.Init(new KeyParameter(key)); 139 | } 140 | 141 | public byte[] ComputeHash(byte[] value) 142 | { 143 | var resBuf = new byte[_hmac.GetMacSize()]; 144 | _hmac.BlockUpdate(value, 0, value.Length); 145 | _hmac.DoFinal(resBuf, 0); 146 | 147 | return resBuf; 148 | } 149 | } 150 | } -------------------------------------------------------------------------------- /WebPush/Util/JwsSigner.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Text.Json; 6 | using Org.BouncyCastle.Crypto.Digests; 7 | using Org.BouncyCastle.Crypto.Parameters; 8 | using Org.BouncyCastle.Crypto.Signers; 9 | 10 | namespace WebPush.Util 11 | { 12 | internal class JwsSigner 13 | { 14 | private readonly ECPrivateKeyParameters _privateKey; 15 | 16 | public JwsSigner(ECPrivateKeyParameters privateKey) 17 | { 18 | _privateKey = privateKey; 19 | } 20 | 21 | /// 22 | /// Generates a Jws Signature. 23 | /// 24 | /// 25 | /// 26 | /// 27 | public string GenerateSignature(Dictionary header, Dictionary payload) 28 | { 29 | var securedInput = SecureInput(header, payload); 30 | var message = Encoding.UTF8.GetBytes(securedInput); 31 | 32 | var hashedMessage = Sha256Hash(message); 33 | 34 | var signer = new ECDsaSigner(); 35 | signer.Init(true, _privateKey); 36 | var results = signer.GenerateSignature(hashedMessage); 37 | 38 | // Concated to create signature 39 | var a = results[0].ToByteArrayUnsigned(); 40 | var b = results[1].ToByteArrayUnsigned(); 41 | 42 | // a,b are required to be exactly the same length of bytes 43 | if (a.Length != b.Length) 44 | { 45 | var largestLength = Math.Max(a.Length, b.Length); 46 | a = ByteArrayPadLeft(a, largestLength); 47 | b = ByteArrayPadLeft(b, largestLength); 48 | } 49 | 50 | var signature = UrlBase64.Encode(a.Concat(b).ToArray()); 51 | return $"{securedInput}.{signature}"; 52 | } 53 | 54 | private static string SecureInput(Dictionary header, Dictionary payload) 55 | { 56 | var encodeHeader = UrlBase64.Encode(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(header))); 57 | var encodePayload = UrlBase64.Encode(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload))); 58 | 59 | return $"{encodeHeader}.{encodePayload}"; 60 | } 61 | 62 | private static byte[] ByteArrayPadLeft(byte[] src, int size) 63 | { 64 | var dst = new byte[size]; 65 | var startAt = dst.Length - src.Length; 66 | Array.Copy(src, 0, dst, startAt, src.Length); 67 | return dst; 68 | } 69 | 70 | private static byte[] Sha256Hash(byte[] message) 71 | { 72 | var sha256Digest = new Sha256Digest(); 73 | sha256Digest.BlockUpdate(message, 0, message.Length); 74 | var hash = new byte[sha256Digest.GetDigestSize()]; 75 | sha256Digest.DoFinal(hash, 0); 76 | return hash; 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /WebPush/Util/UrlBase64.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace WebPush.Util 4 | { 5 | internal static class UrlBase64 6 | { 7 | /// 8 | /// Decodes a url-safe base64 string into bytes 9 | /// 10 | /// 11 | /// 12 | public static byte[] Decode(string base64) 13 | { 14 | base64 = base64.Replace('-', '+').Replace('_', '/'); 15 | 16 | while (base64.Length % 4 != 0) 17 | { 18 | base64 += "="; 19 | } 20 | 21 | return Convert.FromBase64String(base64); 22 | } 23 | 24 | /// 25 | /// Encodes bytes into url-safe base64 string 26 | /// 27 | /// 28 | /// 29 | public static string Encode(byte[] data) 30 | { 31 | return Convert.ToBase64String(data).Replace('+', '-').Replace('/', '_').TrimEnd('='); 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /WebPush/VapidHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Org.BouncyCastle.Crypto.Parameters; 4 | using WebPush.Util; 5 | 6 | namespace WebPush 7 | { 8 | public static class VapidHelper 9 | { 10 | /// 11 | /// Generate vapid keys 12 | /// 13 | public static VapidDetails GenerateVapidKeys() 14 | { 15 | var results = new VapidDetails(); 16 | 17 | var keys = ECKeyHelper.GenerateKeys(); 18 | var publicKey = ((ECPublicKeyParameters) keys.Public).Q.GetEncoded(false); 19 | var privateKey = ((ECPrivateKeyParameters) keys.Private).D.ToByteArrayUnsigned(); 20 | 21 | results.PublicKey = UrlBase64.Encode(publicKey); 22 | results.PrivateKey = UrlBase64.Encode(ByteArrayPadLeft(privateKey, 32)); 23 | 24 | return results; 25 | } 26 | 27 | /// 28 | /// This method takes the required VAPID parameters and returns the required 29 | /// header to be added to a Web Push Protocol Request. 30 | /// 31 | /// This must be the origin of the push service. 32 | /// This should be a URL or a 'mailto:' email address 33 | /// The VAPID public key as a base64 encoded string 34 | /// The VAPID private key as a base64 encoded string 35 | /// The expiration of the VAPID JWT. 36 | /// A dictionary of header key/value pairs. 37 | public static Dictionary GetVapidHeaders(string audience, string subject, string publicKey, 38 | string privateKey, long expiration = -1) 39 | { 40 | ValidateAudience(audience); 41 | ValidateSubject(subject); 42 | ValidatePublicKey(publicKey); 43 | ValidatePrivateKey(privateKey); 44 | 45 | var decodedPrivateKey = UrlBase64.Decode(privateKey); 46 | 47 | if (expiration == -1) 48 | { 49 | expiration = UnixTimeNow() + 43200; 50 | } 51 | else 52 | { 53 | ValidateExpiration(expiration); 54 | } 55 | 56 | 57 | var header = new Dictionary {{"typ", "JWT"}, {"alg", "ES256"}}; 58 | 59 | var jwtPayload = new Dictionary {{"aud", audience}, {"exp", expiration}, {"sub", subject}}; 60 | 61 | var signingKey = ECKeyHelper.GetPrivateKey(decodedPrivateKey); 62 | 63 | var signer = new JwsSigner(signingKey); 64 | var token = signer.GenerateSignature(header, jwtPayload); 65 | 66 | var results = new Dictionary 67 | { 68 | {"Authorization", "WebPush " + token}, {"Crypto-Key", "p256ecdsa=" + publicKey} 69 | }; 70 | 71 | return results; 72 | } 73 | 74 | public static void ValidateAudience(string audience) 75 | { 76 | if (string.IsNullOrEmpty(audience)) 77 | { 78 | throw new ArgumentException(@"No audience could be generated for VAPID."); 79 | } 80 | 81 | if (audience.Length == 0) 82 | { 83 | throw new ArgumentException( 84 | @"The audience value must be a string containing the origin of a push service. " + audience); 85 | } 86 | 87 | if (!Uri.IsWellFormedUriString(audience, UriKind.Absolute)) 88 | { 89 | throw new ArgumentException(@"VAPID audience is not a url."); 90 | } 91 | } 92 | 93 | public static void ValidateSubject(string subject) 94 | { 95 | if (string.IsNullOrEmpty(subject)) 96 | { 97 | throw new ArgumentException(@"A subject is required"); 98 | } 99 | 100 | if (subject.Length == 0) 101 | { 102 | throw new ArgumentException(@"The subject value must be a string containing a url or mailto: address."); 103 | } 104 | 105 | if (!subject.StartsWith("mailto:")) 106 | { 107 | if (!Uri.IsWellFormedUriString(subject, UriKind.Absolute)) 108 | { 109 | throw new ArgumentException(@"Subject is not a valid URL or mailto address"); 110 | } 111 | } 112 | } 113 | 114 | public static void ValidatePublicKey(string publicKey) 115 | { 116 | if (string.IsNullOrEmpty(publicKey)) 117 | { 118 | throw new ArgumentException(@"Valid public key not set"); 119 | } 120 | 121 | var decodedPublicKey = UrlBase64.Decode(publicKey); 122 | 123 | if (decodedPublicKey.Length != 65) 124 | { 125 | throw new ArgumentException(@"Vapid public key must be 65 characters long when decoded"); 126 | } 127 | } 128 | 129 | public static void ValidatePrivateKey(string privateKey) 130 | { 131 | if (string.IsNullOrEmpty(privateKey)) 132 | { 133 | throw new ArgumentException(@"Valid private key not set"); 134 | } 135 | 136 | var decodedPrivateKey = UrlBase64.Decode(privateKey); 137 | 138 | if (decodedPrivateKey.Length != 32) 139 | { 140 | throw new ArgumentException(@"Vapid private key should be 32 bytes long when decoded."); 141 | } 142 | } 143 | 144 | private static void ValidateExpiration(long expiration) 145 | { 146 | if (expiration <= UnixTimeNow()) 147 | { 148 | throw new ArgumentException(@"Vapid expiration must be a unix timestamp in the future"); 149 | } 150 | } 151 | 152 | private static long UnixTimeNow() 153 | { 154 | var timeSpan = DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0); 155 | return (long) timeSpan.TotalSeconds; 156 | } 157 | 158 | private static byte[] ByteArrayPadLeft(byte[] src, int size) 159 | { 160 | var dst = new byte[size]; 161 | var startAt = dst.Length - src.Length; 162 | Array.Copy(src, 0, dst, startAt, src.Length); 163 | return dst; 164 | } 165 | } 166 | } -------------------------------------------------------------------------------- /WebPush/WebPush.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net462;net471;net48;netstandard2.0;netstandard2.1;net5.0;net6.0;net7.0;net8.0 5 | true 6 | 1.0.12 7 | Cory Thompson 8 | 9 | 10 | Web Push library for C# 11 | https://github.com/web-push-libs/web-push-csharp/blob/master/LICENSE 12 | https://github.com/web-push-libs/web-push-csharp/ 13 | https://github.com/web-push-libs/web-push-csharp/ 14 | web push notifications vapid 15 | true 16 | true 17 | snupkg 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /WebPush/WebPushClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Net; 4 | using System.Net.Http; 5 | using System.Net.Http.Headers; 6 | using System.Runtime.CompilerServices; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | using WebPush.Model; 10 | using WebPush.Util; 11 | 12 | [assembly: InternalsVisibleTo("WebPush.Test")] 13 | 14 | namespace WebPush 15 | { 16 | public class WebPushClient : IWebPushClient 17 | { 18 | // default TTL is 4 weeks. 19 | private const int DefaultTtl = 2419200; 20 | private readonly HttpClientHandler _httpClientHandler; 21 | 22 | private string _gcmApiKey; 23 | private HttpClient _httpClient; 24 | private VapidDetails _vapidDetails; 25 | 26 | // Used so we only cleanup internally created http clients 27 | private bool _isHttpClientInternallyCreated; 28 | 29 | public WebPushClient() 30 | { 31 | 32 | } 33 | 34 | public WebPushClient(HttpClient httpClient) 35 | { 36 | _httpClient = httpClient; 37 | } 38 | 39 | public WebPushClient(HttpClientHandler httpClientHandler) 40 | { 41 | _httpClientHandler = httpClientHandler; 42 | } 43 | 44 | protected HttpClient HttpClient 45 | { 46 | get 47 | { 48 | if (_httpClient != null) 49 | { 50 | return _httpClient; 51 | } 52 | 53 | _isHttpClientInternallyCreated = true; 54 | _httpClient = _httpClientHandler == null 55 | ? new HttpClient() 56 | : new HttpClient(_httpClientHandler); 57 | 58 | return _httpClient; 59 | } 60 | } 61 | 62 | /// 63 | /// When sending messages to a GCM endpoint you need to set the GCM API key 64 | /// by either calling setGcmApiKey() or passing in the API key as an option 65 | /// to sendNotification() 66 | /// 67 | /// The API key to send with the GCM request. 68 | public void SetGcmApiKey(string gcmApiKey) 69 | { 70 | if (gcmApiKey == null) 71 | { 72 | _gcmApiKey = null; 73 | return; 74 | } 75 | 76 | if (string.IsNullOrEmpty(gcmApiKey)) 77 | { 78 | throw new ArgumentException(@"The GCM API Key should be a non-empty string or null."); 79 | } 80 | 81 | _gcmApiKey = gcmApiKey; 82 | } 83 | 84 | /// 85 | /// When marking requests where you want to define VAPID details, call this method 86 | /// before sendNotifications() or pass in the details and options to 87 | /// sendNotification. 88 | /// 89 | /// 90 | public void SetVapidDetails(VapidDetails vapidDetails) 91 | { 92 | VapidHelper.ValidateSubject(vapidDetails.Subject); 93 | VapidHelper.ValidatePublicKey(vapidDetails.PublicKey); 94 | VapidHelper.ValidatePrivateKey(vapidDetails.PrivateKey); 95 | 96 | _vapidDetails = vapidDetails; 97 | } 98 | 99 | /// 100 | /// When marking requests where you want to define VAPID details, call this method 101 | /// before sendNotifications() or pass in the details and options to 102 | /// sendNotification. 103 | /// 104 | /// This must be either a URL or a 'mailto:' address 105 | /// The public VAPID key as a base64 encoded string 106 | /// The private VAPID key as a base64 encoded string 107 | public void SetVapidDetails(string subject, string publicKey, string privateKey) 108 | { 109 | SetVapidDetails(new VapidDetails(subject, publicKey, privateKey)); 110 | } 111 | 112 | /// 113 | /// To get a request without sending a push notification call this method. 114 | /// This method will throw an ArgumentException if there is an issue with the input. 115 | /// 116 | /// The PushSubscription you wish to send the notification to. 117 | /// The payload you wish to send to the user 118 | /// 119 | /// Options for the GCM API key and vapid keys can be passed in if they are unique for each 120 | /// notification. 121 | /// 122 | /// A HttpRequestMessage object that can be sent. 123 | public HttpRequestMessage GenerateRequestDetails(PushSubscription subscription, string payload, 124 | Dictionary options = null) 125 | { 126 | if (!Uri.IsWellFormedUriString(subscription.Endpoint, UriKind.Absolute)) 127 | { 128 | throw new ArgumentException(@"You must pass in a subscription with at least a valid endpoint"); 129 | } 130 | 131 | var request = new HttpRequestMessage(HttpMethod.Post, subscription.Endpoint); 132 | 133 | if (!string.IsNullOrEmpty(payload) && (string.IsNullOrEmpty(subscription.Auth) || 134 | string.IsNullOrEmpty(subscription.P256DH))) 135 | { 136 | throw new ArgumentException( 137 | @"To send a message with a payload, the subscription must have 'auth' and 'p256dh' keys."); 138 | } 139 | 140 | var currentGcmApiKey = _gcmApiKey; 141 | var currentVapidDetails = _vapidDetails; 142 | var timeToLive = DefaultTtl; 143 | var extraHeaders = new Dictionary(); 144 | 145 | if (options != null) 146 | { 147 | var validOptionsKeys = new List { "headers", "gcmAPIKey", "vapidDetails", "TTL" }; 148 | foreach (var key in options.Keys) 149 | { 150 | if (!validOptionsKeys.Contains(key)) 151 | { 152 | throw new ArgumentException(key + " is an invalid options. The valid options are" + 153 | string.Join(",", validOptionsKeys)); 154 | } 155 | } 156 | 157 | if (options.ContainsKey("headers")) 158 | { 159 | var headers = options["headers"] as Dictionary; 160 | 161 | extraHeaders = headers ?? throw new ArgumentException("options.headers must be of type Dictionary"); 162 | } 163 | 164 | if (options.ContainsKey("gcmAPIKey")) 165 | { 166 | var gcmApiKey = options["gcmAPIKey"] as string; 167 | 168 | currentGcmApiKey = gcmApiKey ?? throw new ArgumentException("options.gcmAPIKey must be of type string"); 169 | } 170 | 171 | if (options.ContainsKey("vapidDetails")) 172 | { 173 | var vapidDetails = options["vapidDetails"] as VapidDetails; 174 | currentVapidDetails = vapidDetails ?? throw new ArgumentException("options.vapidDetails must be of type VapidDetails"); 175 | } 176 | 177 | if (options.ContainsKey("TTL")) 178 | { 179 | var ttl = options["TTL"] as int?; 180 | if (ttl == null) 181 | { 182 | throw new ArgumentException("options.TTL must be of type int"); 183 | } 184 | 185 | //at this stage ttl cannot be null. 186 | timeToLive = (int)ttl; 187 | } 188 | } 189 | 190 | string cryptoKeyHeader = null; 191 | request.Headers.Add("TTL", timeToLive.ToString()); 192 | 193 | foreach (var header in extraHeaders) 194 | { 195 | request.Headers.Add(header.Key, header.Value.ToString()); 196 | } 197 | 198 | if (!string.IsNullOrEmpty(payload)) 199 | { 200 | if (string.IsNullOrEmpty(subscription.P256DH) || string.IsNullOrEmpty(subscription.Auth)) 201 | { 202 | throw new ArgumentException( 203 | @"Unable to send a message with payload to this subscription since it doesn't have the required encryption key"); 204 | } 205 | 206 | var encryptedPayload = EncryptPayload(subscription, payload); 207 | 208 | request.Content = new ByteArrayContent(encryptedPayload.Payload); 209 | request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); 210 | request.Content.Headers.ContentLength = encryptedPayload.Payload.Length; 211 | request.Content.Headers.ContentEncoding.Add("aesgcm"); 212 | request.Headers.Add("Encryption", "salt=" + encryptedPayload.Base64EncodeSalt()); 213 | cryptoKeyHeader = @"dh=" + encryptedPayload.Base64EncodePublicKey(); 214 | } 215 | else 216 | { 217 | request.Content = new ByteArrayContent(new byte[0]); 218 | request.Content.Headers.ContentLength = 0; 219 | } 220 | 221 | var isGcm = subscription.Endpoint.StartsWith(@"https://android.googleapis.com/gcm/send"); 222 | var isFcm = subscription.Endpoint.StartsWith(@"https://fcm.googleapis.com/fcm/send/"); 223 | 224 | if (isGcm) 225 | { 226 | if (!string.IsNullOrEmpty(currentGcmApiKey)) 227 | { 228 | request.Headers.TryAddWithoutValidation("Authorization", "key=" + currentGcmApiKey); 229 | } 230 | } 231 | else if (currentVapidDetails != null) 232 | { 233 | var uri = new Uri(subscription.Endpoint); 234 | var audience = uri.Scheme + @"://" + uri.Host; 235 | 236 | var vapidHeaders = VapidHelper.GetVapidHeaders(audience, currentVapidDetails.Subject, 237 | currentVapidDetails.PublicKey, currentVapidDetails.PrivateKey, currentVapidDetails.Expiration); 238 | request.Headers.Add(@"Authorization", vapidHeaders["Authorization"]); 239 | if (string.IsNullOrEmpty(cryptoKeyHeader)) 240 | { 241 | cryptoKeyHeader = vapidHeaders["Crypto-Key"]; 242 | } 243 | else 244 | { 245 | cryptoKeyHeader += @";" + vapidHeaders["Crypto-Key"]; 246 | } 247 | } 248 | else if (isFcm && !string.IsNullOrEmpty(currentGcmApiKey)) 249 | { 250 | request.Headers.TryAddWithoutValidation("Authorization", "key=" + currentGcmApiKey); 251 | } 252 | 253 | request.Headers.Add("Crypto-Key", cryptoKeyHeader); 254 | return request; 255 | } 256 | 257 | private static EncryptionResult EncryptPayload(PushSubscription subscription, string payload) 258 | { 259 | try 260 | { 261 | return Encryptor.Encrypt(subscription.P256DH, subscription.Auth, payload); 262 | } 263 | catch (Exception ex) 264 | { 265 | if (ex is FormatException || ex is ArgumentException) 266 | { 267 | throw new InvalidEncryptionDetailsException("Unable to encrypt the payload with the encryption key of this subscription.", subscription); 268 | } 269 | 270 | throw; 271 | } 272 | } 273 | 274 | /// 275 | /// To send a push notification call this method with a subscription, optional payload and any options 276 | /// Will exception if unsuccessful 277 | /// 278 | /// The PushSubscription you wish to send the notification to. 279 | /// The payload you wish to send to the user 280 | /// 281 | /// Options for the GCM API key and vapid keys can be passed in if they are unique for each 282 | /// notification. 283 | /// 284 | public void SendNotification(PushSubscription subscription, string payload = null, 285 | Dictionary options = null) 286 | { 287 | SendNotificationAsync(subscription, payload, options).ConfigureAwait(false).GetAwaiter().GetResult(); 288 | } 289 | 290 | /// 291 | /// To send a push notification call this method with a subscription, optional payload and any options 292 | /// Will exception if unsuccessful 293 | /// 294 | /// The PushSubscription you wish to send the notification to. 295 | /// The payload you wish to send to the user 296 | /// The vapid details for the notification. 297 | public void SendNotification(PushSubscription subscription, string payload, VapidDetails vapidDetails) 298 | { 299 | var options = new Dictionary { ["vapidDetails"] = vapidDetails }; 300 | SendNotification(subscription, payload, options); 301 | } 302 | 303 | /// 304 | /// To send a push notification call this method with a subscription, optional payload and any options 305 | /// Will exception if unsuccessful 306 | /// 307 | /// The PushSubscription you wish to send the notification to. 308 | /// The payload you wish to send to the user 309 | /// The GCM API key 310 | public void SendNotification(PushSubscription subscription, string payload, string gcmApiKey) 311 | { 312 | var options = new Dictionary { ["gcmAPIKey"] = gcmApiKey }; 313 | SendNotification(subscription, payload, options); 314 | } 315 | 316 | 317 | /// 318 | /// To send a push notification asynchronous call this method with a subscription, optional payload and any options 319 | /// Will exception if unsuccessful 320 | /// 321 | /// The PushSubscription you wish to send the notification to. 322 | /// The payload you wish to send to the user 323 | /// 324 | /// Options for the GCM API key and vapid keys can be passed in if they are unique for each 325 | /// notification. 326 | /// 327 | /// The cancellation token to cancel operation. 328 | public async Task SendNotificationAsync(PushSubscription subscription, string payload = null, 329 | Dictionary options = null, CancellationToken cancellationToken = default) 330 | { 331 | var request = GenerateRequestDetails(subscription, payload, options); 332 | var response = await HttpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); 333 | 334 | await HandleResponse(response, subscription).ConfigureAwait(false); 335 | } 336 | 337 | /// 338 | /// To send a push notification asynchronous call this method with a subscription, optional payload and any options 339 | /// Will exception if unsuccessful 340 | /// 341 | /// The PushSubscription you wish to send the notification to. 342 | /// The payload you wish to send to the user 343 | /// The vapid details for the notification. 344 | /// 345 | public async Task SendNotificationAsync(PushSubscription subscription, string payload, 346 | VapidDetails vapidDetails, CancellationToken cancellationToken = default) 347 | { 348 | var options = new Dictionary { ["vapidDetails"] = vapidDetails }; 349 | await SendNotificationAsync(subscription, payload, options, cancellationToken).ConfigureAwait(false); 350 | } 351 | 352 | /// 353 | /// To send a push notification asynchronous call this method with a subscription, optional payload and any options 354 | /// Will exception if unsuccessful 355 | /// 356 | /// The PushSubscription you wish to send the notification to. 357 | /// The payload you wish to send to the user 358 | /// The GCM API key 359 | /// 360 | public async Task SendNotificationAsync(PushSubscription subscription, string payload, string gcmApiKey, CancellationToken cancellationToken = default) 361 | { 362 | var options = new Dictionary { ["gcmAPIKey"] = gcmApiKey }; 363 | await SendNotificationAsync(subscription, payload, options, cancellationToken).ConfigureAwait(false); 364 | } 365 | 366 | /// 367 | /// Handle Web Push responses. 368 | /// 369 | /// 370 | /// 371 | private static async Task HandleResponse(HttpResponseMessage response, PushSubscription subscription) 372 | { 373 | // Successful 374 | if (response.IsSuccessStatusCode) 375 | { 376 | return; 377 | } 378 | 379 | // Error 380 | var responseCodeMessage = @"Received unexpected response code: " + (int)response.StatusCode; 381 | switch (response.StatusCode) 382 | { 383 | case HttpStatusCode.BadRequest: 384 | responseCodeMessage = "Bad Request"; 385 | break; 386 | 387 | case HttpStatusCode.RequestEntityTooLarge: 388 | responseCodeMessage = "Payload too large"; 389 | break; 390 | 391 | case (HttpStatusCode)429: 392 | responseCodeMessage = "Too many request"; 393 | break; 394 | 395 | case HttpStatusCode.NotFound: 396 | case HttpStatusCode.Gone: 397 | responseCodeMessage = "Subscription no longer valid"; 398 | break; 399 | } 400 | 401 | string details = null; 402 | if (response.Content != null) 403 | { 404 | details = await response.Content.ReadAsStringAsync().ConfigureAwait(false); 405 | } 406 | 407 | var message = string.IsNullOrEmpty(details) 408 | ? responseCodeMessage 409 | : $"{responseCodeMessage}. Details: {details}"; 410 | 411 | throw new WebPushException(message, subscription, response); 412 | } 413 | 414 | public void Dispose() 415 | { 416 | if (_httpClient != null && _isHttpClientInternallyCreated) 417 | { 418 | _httpClient.Dispose(); 419 | _httpClient = null; 420 | } 421 | } 422 | } 423 | } --------------------------------------------------------------------------------