Skip to content

Commit fd82695

Browse files
committed
Requesting delivery
1 parent 8df7890 commit fd82695

File tree

14 files changed

+656
-3
lines changed

14 files changed

+656
-3
lines changed
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
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 Demo.AspNetCore.PushNotifications.Services.PushService.Client.Internals;
10+
11+
namespace Demo.AspNetCore.PushNotifications.Services.PushService.Client.Authentication
12+
{
13+
internal class VapidAuthentication
14+
{
15+
#region Structures
16+
public readonly struct WebPushSchemeHeadersValues
17+
{
18+
public string AuthenticationHeaderValueParameter { get; }
19+
20+
public string CryptoKeyHeaderValue { get; }
21+
22+
public WebPushSchemeHeadersValues(string authenticationHeaderValueParameter, string cryptoKeyHeaderValue)
23+
: this()
24+
{
25+
AuthenticationHeaderValueParameter = authenticationHeaderValueParameter;
26+
CryptoKeyHeaderValue = cryptoKeyHeaderValue;
27+
}
28+
}
29+
#endregion
30+
31+
#region Fields
32+
private const string AUDIENCE_CLAIM = "aud";
33+
private const string EXPIRATION_CLAIM = "exp";
34+
private const string SUBJECT_CLAIM = "sub";
35+
private const string P256ECDSA_PREFIX = "p256ecdsa=";
36+
private const string VAPID_AUTHENTICATION_HEADER_VALUE_PARAMETER_FORMAT = "t={0}, k={1}";
37+
private const int DEFAULT_EXPIRATION = 43200;
38+
private const int MAXIMUM_EXPIRATION = 86400;
39+
40+
private string _subject;
41+
private string _publicKey;
42+
private string _privateKey;
43+
private ECPrivateKeyParameters _privateSigningKey;
44+
private int _expiration;
45+
46+
private static readonly DateTime _unixEpoch = new DateTime(1970, 1, 1, 0, 0, 0);
47+
private static readonly Dictionary<string, string> _jwtHeader = new Dictionary<string, string>
48+
{
49+
{ "typ", "JWT" },
50+
{ "alg", "ES256" }
51+
};
52+
#endregion
53+
54+
#region Properties
55+
public string Subject
56+
{
57+
get { return _subject; }
58+
59+
set
60+
{
61+
if (!String.IsNullOrWhiteSpace(value))
62+
{
63+
if (!value.StartsWith("mailto:"))
64+
{
65+
if (!Uri.IsWellFormedUriString(value, UriKind.Absolute) || ((new Uri(value)).Scheme != Uri.UriSchemeHttps))
66+
{
67+
throw new ArgumentException(nameof(Subject), "Subject should include a contact URI for the application server as either a 'mailto: ' (email) or an 'https:' URI");
68+
}
69+
}
70+
71+
_subject = value;
72+
}
73+
else
74+
{
75+
_subject = null;
76+
}
77+
}
78+
}
79+
80+
public string PublicKey
81+
{
82+
get { return _publicKey; }
83+
84+
set
85+
{
86+
if (String.IsNullOrWhiteSpace(value))
87+
{
88+
throw new ArgumentNullException(nameof(PublicKey));
89+
}
90+
91+
byte[] decodedPublicKey = UrlBase64Converter.FromUrlBase64String(value);
92+
if (decodedPublicKey.Length != 65)
93+
{
94+
throw new ArgumentException(nameof(PublicKey), "VAPID public key must be 65 bytes long");
95+
}
96+
97+
_publicKey = value;
98+
}
99+
}
100+
101+
public string PrivateKey
102+
{
103+
get { return _privateKey; }
104+
105+
set
106+
{
107+
if (String.IsNullOrWhiteSpace(value))
108+
{
109+
throw new ArgumentNullException(nameof(PrivateKey));
110+
}
111+
112+
byte[] decodedPrivateKey = UrlBase64Converter.FromUrlBase64String(value);
113+
if (decodedPrivateKey.Length != 32)
114+
{
115+
throw new ArgumentException(nameof(PrivateKey), "VAPID private key should be 32 bytes long");
116+
}
117+
118+
_privateKey = value;
119+
_privateSigningKey = ECKeyHelper.GetECPrivateKeyParameters(decodedPrivateKey);
120+
}
121+
}
122+
123+
public int Expiration
124+
{
125+
get { return _expiration; }
126+
127+
set
128+
{
129+
if ((value <= 0) || (value > MAXIMUM_EXPIRATION))
130+
{
131+
throw new ArgumentOutOfRangeException(nameof(Expiration), "Expiration must be a number of seconds not longer than 24 hours");
132+
}
133+
134+
_expiration = value;
135+
}
136+
}
137+
#endregion
138+
139+
#region Constructor
140+
public VapidAuthentication(string publicKey, string privateKey)
141+
{
142+
PublicKey = publicKey;
143+
PrivateKey = privateKey;
144+
145+
_expiration = DEFAULT_EXPIRATION;
146+
}
147+
#endregion
148+
149+
#region Methods
150+
public string GetVapidSchemeAuthenticationHeaderValueParameter(string audience)
151+
{
152+
return String.Format(VAPID_AUTHENTICATION_HEADER_VALUE_PARAMETER_FORMAT, GetToken(audience), _publicKey);
153+
}
154+
155+
public WebPushSchemeHeadersValues GetWebPushSchemeHeadersValues(string audience)
156+
{
157+
return new WebPushSchemeHeadersValues(GetToken(audience), P256ECDSA_PREFIX + _publicKey);
158+
}
159+
160+
private string GetToken(string audience)
161+
{
162+
if (String.IsNullOrWhiteSpace(audience))
163+
{
164+
throw new ArgumentNullException(nameof(audience));
165+
}
166+
167+
if (!Uri.IsWellFormedUriString(audience, UriKind.Absolute))
168+
{
169+
throw new ArgumentException(nameof(audience), "Audience should be an absolute URL");
170+
}
171+
172+
Dictionary<string, object> jwtBody = GetJwtBody(audience);
173+
174+
return GenerateJwtToken(_jwtHeader, jwtBody);
175+
}
176+
177+
private Dictionary<string, object> GetJwtBody(string audience)
178+
{
179+
Dictionary<string, object> jwtBody = new Dictionary<string, object>
180+
{
181+
{ AUDIENCE_CLAIM, audience },
182+
{ EXPIRATION_CLAIM, GetAbsoluteExpiration(_expiration) }
183+
};
184+
185+
if (_subject != null)
186+
{
187+
jwtBody.Add(SUBJECT_CLAIM, _subject);
188+
}
189+
190+
return jwtBody;
191+
}
192+
193+
private static long GetAbsoluteExpiration(int expirationSeconds)
194+
{
195+
TimeSpan unixEpochOffset = DateTime.UtcNow - _unixEpoch;
196+
197+
return (long)unixEpochOffset.TotalSeconds + expirationSeconds;
198+
}
199+
200+
private string GenerateJwtToken(Dictionary<string, string> jwtHeader, Dictionary<string, object> jwtBody)
201+
{
202+
string jwtInput = UrlBase64Converter.ToUrlBase64String(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(jwtHeader)))
203+
+ "."
204+
+ UrlBase64Converter.ToUrlBase64String(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(jwtBody)));
205+
206+
byte[] jwtInputHash;
207+
using (var sha256Hasher = SHA256.Create())
208+
{
209+
jwtInputHash = sha256Hasher.ComputeHash(Encoding.UTF8.GetBytes(jwtInput));
210+
}
211+
212+
ECDsaSigner jwtSigner = new ECDsaSigner();
213+
jwtSigner.Init(true, _privateSigningKey);
214+
215+
BigInteger[] jwtSignature = jwtSigner.GenerateSignature(jwtInputHash);
216+
217+
byte[] jwtSignatureFirstSegment = jwtSignature[0].ToByteArrayUnsigned();
218+
byte[] jwtSignatureSecondSegment = jwtSignature[1].ToByteArrayUnsigned();
219+
220+
int jwtSignatureSegmentLength = Math.Max(jwtSignatureFirstSegment.Length, jwtSignatureSecondSegment.Length);
221+
byte[] combinedJwtSignature = new byte[2 * jwtSignatureSegmentLength];
222+
ByteArrayCopyWithPadLeft(jwtSignatureFirstSegment, combinedJwtSignature, 0, jwtSignatureSegmentLength);
223+
ByteArrayCopyWithPadLeft(jwtSignatureSecondSegment, combinedJwtSignature, jwtSignatureSegmentLength, jwtSignatureSegmentLength);
224+
225+
return jwtInput + "." + UrlBase64Converter.ToUrlBase64String(combinedJwtSignature);
226+
}
227+
228+
private static void ByteArrayCopyWithPadLeft(byte[] sourceArray, byte[] destinationArray, int destinationIndex, int destinationLengthToUse)
229+
{
230+
if (sourceArray.Length != destinationLengthToUse)
231+
{
232+
destinationIndex += (destinationLengthToUse - sourceArray.Length);
233+
}
234+
235+
Array.Copy(sourceArray, 0, destinationArray, destinationIndex, sourceArray.Length);
236+
}
237+
#endregion
238+
}
239+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
using System;
2+
using System.IO;
3+
using Org.BouncyCastle.Asn1;
4+
using Org.BouncyCastle.Asn1.Nist;
5+
using Org.BouncyCastle.Asn1.X9;
6+
using Org.BouncyCastle.Crypto;
7+
using Org.BouncyCastle.Crypto.Parameters;
8+
using Org.BouncyCastle.OpenSsl;
9+
using Org.BouncyCastle.Security;
10+
11+
namespace Demo.AspNetCore.PushNotifications.Services.PushService.Client.Internals
12+
{
13+
internal static class ECKeyHelper
14+
{
15+
internal static ECPrivateKeyParameters GetECPrivateKeyParameters(byte[] privateKey)
16+
{
17+
Asn1Object derSequence = new DerSequence(
18+
new DerInteger(1),
19+
new DerOctetString(privateKey),
20+
new DerTaggedObject(0, new DerObjectIdentifier("1.2.840.10045.3.1.7"))
21+
);
22+
23+
string pemKey = "-----BEGIN EC PRIVATE KEY-----\n"
24+
+ Convert.ToBase64String(derSequence.GetDerEncoded())
25+
+ "\n-----END EC PRIVATE KEY----";
26+
27+
PemReader pemKeyReader = new PemReader(new StringReader(pemKey));
28+
AsymmetricCipherKeyPair keyPair = (AsymmetricCipherKeyPair)pemKeyReader.ReadObject();
29+
30+
return (ECPrivateKeyParameters)keyPair.Private;
31+
}
32+
33+
internal static ECPublicKeyParameters GetECPublicKeyParameters(byte[] publicKey)
34+
{
35+
Asn1Object derSequence = new DerSequence(
36+
new DerSequence(new DerObjectIdentifier(@"1.2.840.10045.2.1"), new DerObjectIdentifier(@"1.2.840.10045.3.1.7")),
37+
new DerBitString(publicKey)
38+
);
39+
40+
string pemKey = "-----BEGIN PUBLIC KEY-----\n"
41+
+ Convert.ToBase64String(derSequence.GetDerEncoded())
42+
+ "\n-----END PUBLIC KEY-----";
43+
44+
PemReader pemKeyReader = new PemReader(new StringReader(pemKey));
45+
return (ECPublicKeyParameters)pemKeyReader.ReadObject();
46+
}
47+
48+
internal static AsymmetricCipherKeyPair GenerateAsymmetricCipherKeyPair()
49+
{
50+
X9ECParameters ecParameters = NistNamedCurves.GetByName("P-256");
51+
ECDomainParameters ecDomainParameters = new ECDomainParameters(ecParameters.Curve, ecParameters.G, ecParameters.N, ecParameters.H, ecParameters.GetSeed());
52+
53+
IAsymmetricCipherKeyPairGenerator keyPairGenerator = GeneratorUtilities.GetKeyPairGenerator("ECDH");
54+
keyPairGenerator.Init(new ECKeyGenerationParameters(ecDomainParameters, new SecureRandom()));
55+
56+
return keyPairGenerator.GenerateKeyPair();
57+
}
58+
}
59+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using System;
2+
3+
namespace Demo.AspNetCore.PushNotifications.Services.PushService.Client.Internals
4+
{
5+
internal static class UrlBase64Converter
6+
{
7+
internal static byte[] FromUrlBase64String(string input)
8+
{
9+
input = input.Replace('-', '+').Replace('_', '/');
10+
11+
while (input.Length % 4 != 0)
12+
{
13+
input += "=";
14+
}
15+
16+
return Convert.FromBase64String(input);
17+
}
18+
19+
internal static string ToUrlBase64String(byte[] input)
20+
{
21+
return Convert.ToBase64String(input).Replace('+', '-').Replace('/', '_').TrimEnd('=');
22+
}
23+
}
24+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using System;
2+
3+
namespace Demo.AspNetCore.PushNotifications.Services.PushService.Client
4+
{
5+
internal class PushMessage
6+
{
7+
#region Fields
8+
private int? _timeToLive;
9+
#endregion
10+
11+
#region Properties
12+
public string Content { get; set; }
13+
14+
public int? TimeToLive
15+
{
16+
get { return _timeToLive; }
17+
18+
set
19+
{
20+
if (value.HasValue && (value.Value < 0))
21+
{
22+
throw new ArgumentOutOfRangeException(nameof(TimeToLive), "The TTL must be a non-negative integer");
23+
}
24+
25+
_timeToLive = value;
26+
}
27+
}
28+
#endregion
29+
30+
#region Constructors
31+
public PushMessage(string content)
32+
{
33+
Content = content;
34+
}
35+
#endregion
36+
}
37+
}

0 commit comments

Comments
 (0)