├── .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 |
6 |
7 |
8 |
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 | Browser |
206 | Push without Payload |
207 | Push with Payload |
208 | VAPID |
209 | Notes |
210 |
211 |
212 |
213 |
214 | Chrome |
215 |
216 | ✓ v42+ |
217 |
218 | ✓ v50+ |
219 |
220 | ✓ v52+ |
221 | In v51 and less, the `gcm_sender_id` is needed to get a push subscription. |
222 |
223 |
224 |
225 | Firefox |
226 |
227 |
228 | ✓ v44+ |
229 |
230 |
231 | ✓ v44+ |
232 |
233 |
234 | ✓ v46+ |
235 |
236 | |
237 |
238 |
239 |
240 | Opera |
241 |
242 |
243 | ✓ v39+ Android *
244 |
245 |
246 | ✓ v42+ Desktop
247 | |
248 |
249 | ✓ v39+ Android *
250 |
251 |
252 | ✓ v42+ Desktop
253 | |
254 |
255 |
256 | ✓ v42+ Desktop |
257 |
258 |
259 | * The `gcm_sender_id` is needed to get a push subscription.
260 | |
261 |
262 |
263 |
264 | Edge |
265 |
266 |
267 | ✓ v17+ |
268 |
269 |
270 | ✓ v17+ |
271 |
272 |
273 | ✓ v17+ |
274 |
275 | |
276 |
277 |
278 | Safari |
279 |
280 |
281 | ✗ |
282 |
283 |
284 | ✗ |
285 |
286 |
287 | ✗ |
288 |
289 | |
290 |
291 |
292 |
293 | Samsung Internet Browser |
294 |
295 | ✓ v4.0.10-53+ |
296 |
297 | ✗ |
298 |
299 |
300 | ✗ |
301 |
302 | The `gcm_sender_id` is needed to get a push subscription. |
303 |
304 |
305 |
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 | }
--------------------------------------------------------------------------------