Skip to content

Commit 7a33d95

Browse files
committed
Push service client
1 parent b05caae commit 7a33d95

File tree

2 files changed

+265
-0
lines changed

2 files changed

+265
-0
lines changed
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
using System;
2+
using System.Net;
3+
using System.Net.Http;
4+
using System.Net.Http.Headers;
5+
using System.Text;
6+
using System.Threading;
7+
using System.Globalization;
8+
using System.Threading.Tasks;
9+
using System.Security.Cryptography;
10+
using Org.BouncyCastle.Crypto;
11+
using Org.BouncyCastle.Crypto.Parameters;
12+
using Org.BouncyCastle.Security;
13+
using Lib.Net.Http.EncryptedContentEncoding;
14+
using Lib.Net.Http.WebPush.Internals;
15+
using Lib.Net.Http.WebPush.Authentication;
16+
17+
namespace Lib.Net.Http.WebPush
18+
{
19+
/// <summary>
20+
/// A Web Push Protocol compliant client for push service.
21+
/// </summary>
22+
/// <remarks>
23+
/// The <see cref="PushServiceClient"/> should be considered an expensive object as it internally holds an instance of <see cref="HttpClient"/> class. In order to avoid Improper Instantiation antipattern a shared singleton instance should be created or a pool of reusable instances should be used.
24+
/// </remarks>
25+
public class PushServiceClient
26+
{
27+
#region Fields
28+
private const string TTL_HEADER_NAME = "TTL";
29+
private const string URGENCY_HEADER_NAME = "Urgency";
30+
private const string CRYPTO_KEY_HEADER_NAME = "Crypto-Key";
31+
private const string WEBPUSH_AUTHENTICATION_SCHEME = "WebPush";
32+
33+
private const int DEFAULT_TIME_TO_LIVE = 2419200;
34+
35+
private const string KEYING_MATERIAL_INFO_PARAMETER_PREFIX = "WebPush: info";
36+
private const byte KEYING_MATERIAL_INFO_PARAMETER_DELIMITER = 1;
37+
private const int KEYING_MATERIAL_INFO_PARAMETER_LENGTH = 32;
38+
39+
private const int CONTENT_RECORD_SIZE = 4096;
40+
41+
private static readonly byte[] _keyingMaterialInfoParameterPrefix = Encoding.ASCII.GetBytes(KEYING_MATERIAL_INFO_PARAMETER_PREFIX);
42+
43+
private int _defaultTimeToLive = DEFAULT_TIME_TO_LIVE;
44+
45+
private readonly HttpClient _httpClient = new HttpClient();
46+
#endregion
47+
48+
#region Properties
49+
/// <summary>
50+
/// Gets or sets the default time (in seconds) for which the message should be retained by push service. It will be used when <see cref="PushMessage.TimeToLive"/> is not set.
51+
/// </summary>
52+
public int DefaultTimeToLive
53+
{
54+
get { return _defaultTimeToLive; }
55+
56+
set
57+
{
58+
if (value < 0)
59+
{
60+
throw new ArgumentOutOfRangeException(nameof(DefaultTimeToLive), "The TTL must be a non-negative integer");
61+
}
62+
63+
_defaultTimeToLive = value;
64+
}
65+
}
66+
67+
/// <summary>
68+
/// Gets or sets the default authentication details.
69+
/// </summary>
70+
public VapidAuthentication DefaultAuthentication { get; set; }
71+
#endregion
72+
73+
#region Methods
74+
/// <summary>
75+
/// Requests delivery of push message by push service as an asynchronous operation.
76+
/// </summary>
77+
/// <param name="subscription">The push service subscription.</param>
78+
/// <param name="message">The push message.</param>
79+
/// <returns>The task object representing the asynchronous operation.</returns>
80+
public Task RequestPushMessageDeliveryAsync(PushSubscription subscription, PushMessage message)
81+
{
82+
return RequestPushMessageDeliveryAsync(subscription, message, null, CancellationToken.None);
83+
}
84+
85+
/// <summary>
86+
/// Requests delivery of push message by push service as an asynchronous operation.
87+
/// </summary>
88+
/// <param name="subscription">The push service subscription.</param>
89+
/// <param name="message">The push message.</param>
90+
/// <param name="cancellationToken">The cancellation token to cancel operation.</param>
91+
/// <returns>The task object representing the asynchronous operation.</returns>
92+
public Task RequestPushMessageDeliveryAsync(PushSubscription subscription, PushMessage message, CancellationToken cancellationToken)
93+
{
94+
return RequestPushMessageDeliveryAsync(subscription, message, null, cancellationToken);
95+
}
96+
97+
/// <summary>
98+
/// Requests delivery of push message by push service as an asynchronous operation.
99+
/// </summary>
100+
/// <param name="subscription">The push service subscription.</param>
101+
/// <param name="message">The push message.</param>
102+
/// <param name="authentication">The authentication details.</param>
103+
/// <returns>The task object representing the asynchronous operation.</returns>
104+
public Task RequestPushMessageDeliveryAsync(PushSubscription subscription, PushMessage message, VapidAuthentication authentication)
105+
{
106+
return RequestPushMessageDeliveryAsync(subscription, message, authentication, CancellationToken.None);
107+
}
108+
109+
/// <summary>
110+
/// Requests delivery of push message by push service as an asynchronous operation.
111+
/// </summary>
112+
/// <param name="subscription">The push service subscription.</param>
113+
/// <param name="message">The push message.</param>
114+
/// <param name="authentication">The authentication details.</param>
115+
/// <param name="cancellationToken">The cancellation token to cancel operation.</param>
116+
/// <returns>The task object representing the asynchronous operation.</returns>
117+
public async Task RequestPushMessageDeliveryAsync(PushSubscription subscription, PushMessage message, VapidAuthentication authentication, CancellationToken cancellationToken)
118+
{
119+
HttpRequestMessage pushMessageDeliveryRequest = PreparePushMessageDeliveryRequest(subscription, message, authentication);
120+
121+
HttpResponseMessage pushMessageDeliveryRequestResponse = await _httpClient.SendAsync(pushMessageDeliveryRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
122+
123+
HandlePushMessageDeliveryRequestResponse(pushMessageDeliveryRequestResponse);
124+
}
125+
126+
private HttpRequestMessage PreparePushMessageDeliveryRequest(PushSubscription subscription, PushMessage message, VapidAuthentication authentication)
127+
{
128+
authentication = authentication ?? DefaultAuthentication;
129+
if (authentication == null)
130+
{
131+
throw new InvalidOperationException("The VAPID authentication information is not available");
132+
}
133+
134+
HttpRequestMessage pushMessageDeliveryRequest = new HttpRequestMessage(HttpMethod.Post, subscription.Endpoint)
135+
{
136+
Headers =
137+
{
138+
{ TTL_HEADER_NAME, (message.TimeToLive ?? DefaultTimeToLive).ToString(CultureInfo.InvariantCulture) }
139+
}
140+
};
141+
pushMessageDeliveryRequest = SetAuthentication(pushMessageDeliveryRequest, subscription, authentication);
142+
pushMessageDeliveryRequest = SetContent(pushMessageDeliveryRequest, subscription, message);
143+
144+
return pushMessageDeliveryRequest;
145+
}
146+
147+
private static HttpRequestMessage SetAuthentication(HttpRequestMessage pushMessageDeliveryRequest, PushSubscription subscription, VapidAuthentication authentication)
148+
{
149+
Uri endpointUri = new Uri(subscription.Endpoint);
150+
string audience = endpointUri.Scheme + @"://" + endpointUri.Host;
151+
152+
VapidAuthentication.WebPushSchemeHeadersValues webPushSchemeHeadersValues = authentication.GetWebPushSchemeHeadersValues(audience);
153+
154+
pushMessageDeliveryRequest.Headers.Authorization = new AuthenticationHeaderValue(WEBPUSH_AUTHENTICATION_SCHEME, webPushSchemeHeadersValues.AuthenticationHeaderValueParameter);
155+
pushMessageDeliveryRequest.Headers.Add(CRYPTO_KEY_HEADER_NAME, webPushSchemeHeadersValues.CryptoKeyHeaderValue);
156+
157+
return pushMessageDeliveryRequest;
158+
}
159+
160+
private static HttpRequestMessage SetContent(HttpRequestMessage pushMessageDeliveryRequest, PushSubscription subscription, PushMessage message)
161+
{
162+
if (String.IsNullOrEmpty(message.Content))
163+
{
164+
pushMessageDeliveryRequest.Content = null;
165+
}
166+
else
167+
{
168+
AsymmetricCipherKeyPair applicationServerKeys = ECKeyHelper.GenerateAsymmetricCipherKeyPair();
169+
byte[] applicationServerPublicKey = ((ECPublicKeyParameters)applicationServerKeys.Public).Q.GetEncoded(false);
170+
171+
pushMessageDeliveryRequest.Content = new Aes128GcmEncodedContent(
172+
new StringContent(message.Content, Encoding.UTF8),
173+
GetKeyingMaterial(subscription, applicationServerKeys.Private, applicationServerPublicKey),
174+
applicationServerPublicKey,
175+
CONTENT_RECORD_SIZE
176+
);
177+
}
178+
179+
return pushMessageDeliveryRequest;
180+
}
181+
182+
private static byte[] GetKeyingMaterial(PushSubscription subscription, AsymmetricKeyParameter applicationServerPrivateKey, byte[] applicationServerPublicKey)
183+
{
184+
IBasicAgreement ecdhAgreement = AgreementUtilities.GetBasicAgreement("ECDH");
185+
ecdhAgreement.Init(applicationServerPrivateKey);
186+
187+
byte[] userAgentPublicKey = UrlBase64Converter.FromUrlBase64String(subscription.GetKey(PushEncryptionKeyName.P256DH));
188+
byte[] authenticationSecret = UrlBase64Converter.FromUrlBase64String(subscription.GetKey(PushEncryptionKeyName.Auth));
189+
byte[] sharedSecret = ecdhAgreement.CalculateAgreement(ECKeyHelper.GetECPublicKeyParameters(userAgentPublicKey)).ToByteArrayUnsigned();
190+
byte[] sharedSecretHash = HmacSha256(authenticationSecret, sharedSecret);
191+
byte[] infoParameter = GetKeyingMaterialInfoParameter(userAgentPublicKey, applicationServerPublicKey);
192+
193+
byte[] keyingMaterial = HmacSha256(sharedSecretHash, infoParameter);
194+
Array.Resize(ref keyingMaterial, KEYING_MATERIAL_INFO_PARAMETER_LENGTH);
195+
196+
return keyingMaterial;
197+
}
198+
199+
private static byte[] GetKeyingMaterialInfoParameter(byte[] userAgentPublicKey, byte[] applicationServerPublicKey)
200+
{
201+
// "WebPush: info" || 0x00 || ua_public || as_public || 0x01
202+
byte[] infoParameter = new byte[_keyingMaterialInfoParameterPrefix.Length + userAgentPublicKey.Length + applicationServerPublicKey.Length + 2];
203+
204+
Array.Copy(_keyingMaterialInfoParameterPrefix, infoParameter, _keyingMaterialInfoParameterPrefix.Length);
205+
int infoParameterIndex = _keyingMaterialInfoParameterPrefix.Length + 1;
206+
207+
Array.Copy(userAgentPublicKey, 0, infoParameter, infoParameterIndex, userAgentPublicKey.Length);
208+
infoParameterIndex += userAgentPublicKey.Length;
209+
210+
Array.Copy(applicationServerPublicKey, 0, infoParameter, infoParameterIndex, applicationServerPublicKey.Length);
211+
212+
infoParameter[infoParameter.Length - 1] = KEYING_MATERIAL_INFO_PARAMETER_DELIMITER;
213+
214+
return infoParameter;
215+
}
216+
217+
private static byte[] HmacSha256(byte[] key, byte[] value)
218+
{
219+
byte[] hash = null;
220+
221+
using (HMACSHA256 hasher = new HMACSHA256(key))
222+
{
223+
hash = hasher.ComputeHash(value);
224+
}
225+
226+
return hash;
227+
}
228+
229+
private static void HandlePushMessageDeliveryRequestResponse(HttpResponseMessage pushMessageDeliveryRequestResponse)
230+
{
231+
if (pushMessageDeliveryRequestResponse.StatusCode != HttpStatusCode.Created)
232+
{
233+
throw new PushServiceClientException(pushMessageDeliveryRequestResponse.ReasonPhrase, pushMessageDeliveryRequestResponse.StatusCode);
234+
}
235+
}
236+
#endregion
237+
}
238+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using System;
2+
using System.Net;
3+
4+
namespace Lib.Net.Http.WebPush
5+
{
6+
/// <summary>
7+
/// An exception representing requesting <see cref="PushMessage"/> delivery failure based on push service response.
8+
/// </summary>
9+
public class PushServiceClientException : Exception
10+
{
11+
/// <summary>
12+
/// Gets or sets the status code of the push service response.
13+
/// </summary>
14+
public HttpStatusCode StatusCode { get; }
15+
16+
/// <summary>
17+
/// Creates new instance of <see cref="PushServiceClientException"/> class.
18+
/// </summary>
19+
/// <param name="message">The message that describes the current exception</param>
20+
/// <param name="statusCode">The status code of the push service response.</param>
21+
public PushServiceClientException(string message, HttpStatusCode statusCode)
22+
: base(message)
23+
{
24+
StatusCode = statusCode;
25+
}
26+
}
27+
}

0 commit comments

Comments
 (0)