Skip to content

Commit ea3e2a9

Browse files
committed
VAPID for Web Push
1 parent e27237a commit ea3e2a9

File tree

2 files changed

+299
-0
lines changed

2 files changed

+299
-0
lines changed
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
using System;
2+
using System.Text;
3+
using System.Collections.Generic;
4+
using System.Security.Cryptography;
5+
using Newtonsoft.Json;
6+
using Org.BouncyCastle.Math;
7+
using Org.BouncyCastle.Crypto.Parameters;
8+
using Org.BouncyCastle.Crypto.Signers;
9+
using Lib.Net.Http.WebPush.Internals;
10+
11+
namespace Lib.Net.Http.WebPush.Authentication
12+
{
13+
/// <summary>
14+
/// Class which provides Voluntary Application Server Identification (VAPID) headers values.
15+
/// </summary>
16+
public class VapidAuthentication
17+
{
18+
#region Structures
19+
/// <summary>
20+
/// Structure providing values for headers used in case of <see cref="VapidAuthenticationScheme.WebPush"/>.
21+
/// </summary>
22+
public readonly struct WebPushSchemeHeadersValues
23+
{
24+
/// <summary>
25+
/// Gets the <see cref="System.Net.Http.Headers.AuthenticationHeaderValue"/> parameter.
26+
/// </summary>
27+
public string AuthenticationHeaderValueParameter { get; }
28+
29+
/// <summary>
30+
/// Gets the Crypto-Key header value.
31+
/// </summary>
32+
public string CryptoKeyHeaderValue { get; }
33+
34+
internal WebPushSchemeHeadersValues(string authenticationHeaderValueParameter, string cryptoKeyHeaderValue)
35+
: this()
36+
{
37+
AuthenticationHeaderValueParameter = authenticationHeaderValueParameter;
38+
CryptoKeyHeaderValue = cryptoKeyHeaderValue;
39+
}
40+
}
41+
#endregion
42+
43+
#region Fields
44+
private const string URI_SCHEME_HTTPS = "https";
45+
private const string AUDIENCE_CLAIM = "aud";
46+
private const string EXPIRATION_CLAIM = "exp";
47+
private const string SUBJECT_CLAIM = "sub";
48+
private const char JWT_SEPARATOR = '.';
49+
50+
private const string P256ECDSA_PREFIX = "p256ecdsa=";
51+
private const string VAPID_AUTHENTICATION_HEADER_VALUE_PARAMETER_FORMAT = "t={0}, k={1}";
52+
53+
private const int DEFAULT_EXPIRATION = 43200;
54+
private const int MAXIMUM_EXPIRATION = 86400;
55+
56+
private string _subject;
57+
private string _publicKey;
58+
private string _privateKey;
59+
private ECPrivateKeyParameters _privateSigningKey;
60+
private int _expiration;
61+
62+
private static readonly DateTime _unixEpoch = new DateTime(1970, 1, 1, 0, 0, 0);
63+
private static readonly Dictionary<string, string> _jwtHeader = new Dictionary<string, string>
64+
{
65+
{ "typ", "JWT" },
66+
{ "alg", "ES256" }
67+
};
68+
#endregion
69+
70+
#region Properties
71+
/// <summary>
72+
/// Gets or sets the contact information for the application server.
73+
/// </summary>
74+
public string Subject
75+
{
76+
get { return _subject; }
77+
78+
set
79+
{
80+
if (!String.IsNullOrWhiteSpace(value))
81+
{
82+
if (!value.StartsWith("mailto:"))
83+
{
84+
if (!Uri.IsWellFormedUriString(value, UriKind.Absolute) || ((new Uri(value)).Scheme != URI_SCHEME_HTTPS))
85+
{
86+
throw new ArgumentException(nameof(Subject), "Subject should include a contact URI for the application server as either a 'mailto: ' (email) or an 'https:' URI");
87+
}
88+
}
89+
90+
_subject = value;
91+
}
92+
else
93+
{
94+
_subject = null;
95+
}
96+
}
97+
}
98+
99+
/// <summary>
100+
/// Gets or sets the Application Server Public Key.
101+
/// </summary>
102+
public string PublicKey
103+
{
104+
get { return _publicKey; }
105+
106+
set
107+
{
108+
if (String.IsNullOrWhiteSpace(value))
109+
{
110+
throw new ArgumentNullException(nameof(PublicKey));
111+
}
112+
113+
byte[] decodedPublicKey = UrlBase64Converter.FromUrlBase64String(value);
114+
if (decodedPublicKey.Length != 65)
115+
{
116+
throw new ArgumentException(nameof(PublicKey), "VAPID public key must be 65 bytes long");
117+
}
118+
119+
_publicKey = value;
120+
}
121+
}
122+
123+
/// <summary>
124+
/// Gets or sets the Application Server Private Key.
125+
/// </summary>
126+
public string PrivateKey
127+
{
128+
get { return _privateKey; }
129+
130+
set
131+
{
132+
if (String.IsNullOrWhiteSpace(value))
133+
{
134+
throw new ArgumentNullException(nameof(PrivateKey));
135+
}
136+
137+
byte[] decodedPrivateKey = UrlBase64Converter.FromUrlBase64String(value);
138+
if (decodedPrivateKey.Length != 32)
139+
{
140+
throw new ArgumentException(nameof(PrivateKey), "VAPID private key should be 32 bytes long");
141+
}
142+
143+
_privateKey = value;
144+
_privateSigningKey = ECKeyHelper.GetECPrivateKeyParameters(decodedPrivateKey);
145+
}
146+
}
147+
148+
/// <summary>
149+
/// Gets or sets the time after which the authentication token expires (in seconds).
150+
/// </summary>
151+
public int Expiration
152+
{
153+
get { return _expiration; }
154+
155+
set
156+
{
157+
if ((value <= 0) || (value > MAXIMUM_EXPIRATION))
158+
{
159+
throw new ArgumentOutOfRangeException(nameof(Expiration), "Expiration must be a number of seconds not longer than 24 hours");
160+
}
161+
162+
_expiration = value;
163+
}
164+
}
165+
#endregion
166+
167+
#region Constructor
168+
/// <summary>
169+
/// Creates new instance of <see cref="VapidAuthentication"/> class.
170+
/// </summary>
171+
/// <param name="publicKey">The Application Server Public Key.</param>
172+
/// <param name="privateKey">The Application Server Private Key.</param>
173+
public VapidAuthentication(string publicKey, string privateKey)
174+
{
175+
PublicKey = publicKey;
176+
PrivateKey = privateKey;
177+
178+
_expiration = DEFAULT_EXPIRATION;
179+
}
180+
#endregion
181+
182+
#region Methods
183+
/// <summary>
184+
/// Gets <see cref="System.Net.Http.Headers.AuthenticationHeaderValue"/> parameter for <see cref="VapidAuthenticationScheme.Vapid"/>.
185+
/// </summary>
186+
/// <param name="audience">The origin of the push resource.</param>
187+
/// <returns>The <see cref="System.Net.Http.Headers.AuthenticationHeaderValue"/> parameter for <see cref="VapidAuthenticationScheme.Vapid"/>.</returns>
188+
public string GetVapidSchemeAuthenticationHeaderValueParameter(string audience)
189+
{
190+
return String.Format(VAPID_AUTHENTICATION_HEADER_VALUE_PARAMETER_FORMAT, GetToken(audience), _publicKey);
191+
}
192+
193+
/// <summary>
194+
/// Gets values for headers used in case of <see cref="VapidAuthenticationScheme.WebPush"/>.
195+
/// </summary>
196+
/// <param name="audience">The origin of the push resource.</param>
197+
/// <returns>The values for headers used in case of <see cref="VapidAuthenticationScheme.WebPush"/>.</returns>
198+
public WebPushSchemeHeadersValues GetWebPushSchemeHeadersValues(string audience)
199+
{
200+
return new WebPushSchemeHeadersValues(GetToken(audience), P256ECDSA_PREFIX + _publicKey);
201+
}
202+
203+
private string GetToken(string audience)
204+
{
205+
if (String.IsNullOrWhiteSpace(audience))
206+
{
207+
throw new ArgumentNullException(nameof(audience));
208+
}
209+
210+
if (!Uri.IsWellFormedUriString(audience, UriKind.Absolute))
211+
{
212+
throw new ArgumentException(nameof(audience), "Audience should be an absolute URL");
213+
}
214+
215+
Dictionary<string, object> jwtBody = GetJwtBody(audience);
216+
217+
return GenerateJwtToken(_jwtHeader, jwtBody);
218+
}
219+
220+
private Dictionary<string, object> GetJwtBody(string audience)
221+
{
222+
Dictionary<string, object> jwtBody = new Dictionary<string, object>
223+
{
224+
{ AUDIENCE_CLAIM, audience },
225+
{ EXPIRATION_CLAIM, GetAbsoluteExpiration(_expiration) }
226+
};
227+
228+
if (_subject != null)
229+
{
230+
jwtBody.Add(SUBJECT_CLAIM, _subject);
231+
}
232+
233+
return jwtBody;
234+
}
235+
236+
private static long GetAbsoluteExpiration(int expirationSeconds)
237+
{
238+
TimeSpan unixEpochOffset = DateTime.UtcNow - _unixEpoch;
239+
240+
return (long)unixEpochOffset.TotalSeconds + expirationSeconds;
241+
}
242+
243+
private string GenerateJwtToken(Dictionary<string, string> jwtHeader, Dictionary<string, object> jwtBody)
244+
{
245+
string jwtInput = UrlBase64Converter.ToUrlBase64String(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(jwtHeader)))
246+
+ JWT_SEPARATOR
247+
+ UrlBase64Converter.ToUrlBase64String(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(jwtBody)));
248+
249+
byte[] jwtInputHash;
250+
using (var sha256Hasher = SHA256.Create())
251+
{
252+
jwtInputHash = sha256Hasher.ComputeHash(Encoding.UTF8.GetBytes(jwtInput));
253+
}
254+
255+
ECDsaSigner jwtSigner = new ECDsaSigner();
256+
jwtSigner.Init(true, _privateSigningKey);
257+
258+
BigInteger[] jwtSignature = jwtSigner.GenerateSignature(jwtInputHash);
259+
260+
byte[] jwtSignatureFirstSegment = jwtSignature[0].ToByteArrayUnsigned();
261+
byte[] jwtSignatureSecondSegment = jwtSignature[1].ToByteArrayUnsigned();
262+
263+
int jwtSignatureSegmentLength = Math.Max(jwtSignatureFirstSegment.Length, jwtSignatureSecondSegment.Length);
264+
byte[] combinedJwtSignature = new byte[2 * jwtSignatureSegmentLength];
265+
ByteArrayCopyWithPadLeft(jwtSignatureFirstSegment, combinedJwtSignature, 0, jwtSignatureSegmentLength);
266+
ByteArrayCopyWithPadLeft(jwtSignatureSecondSegment, combinedJwtSignature, jwtSignatureSegmentLength, jwtSignatureSegmentLength);
267+
268+
return jwtInput + JWT_SEPARATOR + UrlBase64Converter.ToUrlBase64String(combinedJwtSignature);
269+
}
270+
271+
private static void ByteArrayCopyWithPadLeft(byte[] sourceArray, byte[] destinationArray, int destinationIndex, int destinationLengthToUse)
272+
{
273+
if (sourceArray.Length != destinationLengthToUse)
274+
{
275+
destinationIndex += (destinationLengthToUse - sourceArray.Length);
276+
}
277+
278+
Array.Copy(sourceArray, 0, destinationArray, destinationIndex, sourceArray.Length);
279+
}
280+
#endregion
281+
}
282+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace Lib.Net.Http.WebPush.Authentication
2+
{
3+
/// <summary>
4+
/// Voluntary Application Server Identification (VAPID) HTTP authentication schemes.
5+
/// </summary>
6+
public enum VapidAuthenticationScheme
7+
{
8+
/// <summary>
9+
/// The "WebPush" HTTP authentication scheme.
10+
/// </summary>
11+
WebPush,
12+
/// <summary>
13+
/// The "vapid" HTTP authentication scheme.
14+
/// </summary>
15+
Vapid
16+
}
17+
}

0 commit comments

Comments
 (0)