diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 874e367..90e959c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -13,7 +13,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
- dotnet-version: '9.x'
+ dotnet-version: '10.x'
- name: Restore
run: dotnet restore CorePush.sln
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 2477768..6ef757a 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -13,7 +13,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
- dotnet-version: '9.x'
+ dotnet-version: '10.x'
- name: Restore
run: dotnet restore CorePush.sln
diff --git a/CorePush.Tester/CorePush.Tester.csproj b/CorePush.Tester/CorePush.Tester.csproj
index 78feb65..275abeb 100644
--- a/CorePush.Tester/CorePush.Tester.csproj
+++ b/CorePush.Tester/CorePush.Tester.csproj
@@ -2,7 +2,7 @@
Exe
- net9.0
+ net10.0
@@ -11,7 +11,6 @@
-
diff --git a/CorePush.Tester/Program.cs b/CorePush.Tester/Program.cs
index 65fe004..cc33b53 100644
--- a/CorePush.Tester/Program.cs
+++ b/CorePush.Tester/Program.cs
@@ -50,15 +50,13 @@ private static async Task SendApnNotificationAsync()
ServerType = apnServerType,
};
- while (true)
- {
- var apn = new ApnSender(settings, http);
- var payload = new AppleNotification(
- Guid.NewGuid(),
- "Hello World (Message)",
- "Hello World (Title)");
- var response = await apn.SendAsync(payload, apnDeviceToken);
- }
+ var apn = new ApnSender(settings, http);
+ var payload = new AppleNotification(
+ Guid.NewGuid(),
+ "Hello World (Message)",
+ "Hello World (Title)");
+ var response = await apn.SendAsync(payload, apnDeviceToken);
+ Console.WriteLine($"APN Response: {response.StatusCode} - {response.Message}");
}
private static async Task SendFirebaseNotificationAsync()
diff --git a/CorePush/Apple/ApnSender.cs b/CorePush/Apple/ApnSender.cs
index ba58c27..e9673f5 100644
--- a/CorePush/Apple/ApnSender.cs
+++ b/CorePush/Apple/ApnSender.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net.Http;
@@ -7,8 +7,6 @@
using System.Text;
using System.Threading;
using System.Threading.Tasks;
-using Org.BouncyCastle.Crypto.Parameters;
-using Org.BouncyCastle.Security;
using CorePush.Interfaces;
using CorePush.Models;
@@ -43,7 +41,7 @@ public class ApnSender : IApnSender
public ApnSender(ApnSettings settings, HttpClient http) : this(settings, http, new DefaultCorePushJsonSerializer())
{
}
-
+
///
/// Apple push notification sender constructor
///
@@ -55,7 +53,7 @@ public ApnSender(ApnSettings settings, HttpClient http, IJsonSerializer serializ
this.settings = settings ?? throw new ArgumentNullException(nameof(settings));
this.http = http ?? throw new ArgumentNullException(nameof(http));
this.serializer = serializer ?? throw new ArgumentNullException(nameof(serializer));
-
+
if (http.BaseAddress == null)
{
http.BaseAddress = new Uri(servers[settings.ServerType]);
@@ -65,7 +63,7 @@ public ApnSender(ApnSettings settings, HttpClient http, IJsonSerializer serializ
///
/// Serialize and send notification to APN. Please see how your message should be formatted here:
/// https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CreatingtheNotificationPayload.html#//apple_ref/doc/uid/TP40008194-CH10-SW1
- /// !IMPORTANT: If you send many messages at once, make sure to retry those calls. Apple typically doesn't like
+ /// !IMPORTANT: If you send many messages at once, make sure to retry those calls. Apple typically doesn't like
/// to receive too many requests and may occasionally respond with HTTP 429. Just try/catch this call and retry as needed.
///
/// Throws exception when not successful
@@ -78,14 +76,16 @@ public async Task SendAsync(
ApnPushType apnPushType = ApnPushType.Alert,
CancellationToken cancellationToken = default)
{
+ ArgumentException.ThrowIfNullOrWhiteSpace(deviceToken);
+
var path = $"/3/device/{deviceToken}";
var json = serializer.Serialize(notification);
using var message = new HttpRequestMessage(HttpMethod.Post, path);
-
+
message.Version = new Version(2, 0);
message.Content = new StringContent(json);
-
+
message.Headers.Authorization = new AuthenticationHeaderValue("bearer", GetJwtToken());
message.Headers.TryAddWithoutValidation(":method", "POST");
message.Headers.TryAddWithoutValidation(":path", path);
@@ -100,21 +100,22 @@ public async Task SendAsync(
}
using var response = await http.SendAsync(message, cancellationToken);
-
+
var content = await response.Content.ReadAsStringAsync(cancellationToken);
- var error = response.IsSuccessStatusCode
- ? null
- : serializer.Deserialize(content).Reason;
+ var error = response.IsSuccessStatusCode
+ ? null
+ : serializer.Deserialize(content)?.Reason;
return new PushResult((int)response.StatusCode, response.IsSuccessStatusCode, content, error);
}
private string GetJwtToken()
{
- var (token, date) = tokens.GetOrAdd(settings.AppBundleIdentifier, _ => new Tuple(CreateJwtToken(), DateTime.UtcNow));
+ var cacheKey = $"{settings.AppBundleIdentifier}:{settings.P8PrivateKeyId}";
+ var (token, date) = tokens.GetOrAdd(cacheKey, _ => new Tuple(CreateJwtToken(), DateTime.UtcNow));
if (date < DateTime.UtcNow.AddMinutes(-tokenExpiresMinutes))
{
- tokens.TryRemove(settings.AppBundleIdentifier, out _);
+ tokens.TryRemove(cacheKey, out _);
return GetJwtToken();
}
@@ -129,32 +130,26 @@ private string CreateJwtToken()
var payloadBase64 = Base64UrlEncode(payload);
var unsignedJwtData = $"{headerBase64}.{payloadBase64}";
var unsignedJwtBytes = Encoding.UTF8.GetBytes(unsignedJwtData);
-
+
var privateKeyBytes = Convert.FromBase64String(CryptoHelper.CleanP8Key(settings.P8PrivateKey));
- var keyParams = (ECPrivateKeyParameters) PrivateKeyFactory.CreateKey(privateKeyBytes);
- var q = keyParams.Parameters.G.Multiply(keyParams.D).Normalize();
-
- using var dsa = ECDsa.Create(new ECParameters
- {
- Curve = ECCurve.CreateFromValue(keyParams.PublicKeyParamSet.Id),
- D = keyParams.D.ToByteArrayUnsigned(),
- Q =
- {
- X = q.XCoord.GetEncoded(),
- Y = q.YCoord.GetEncoded()
- }
- });
-
- var signature = dsa.SignData(unsignedJwtBytes, 0, unsignedJwtBytes.Length, HashAlgorithmName.SHA256);
+
+ using var dsa = ECDsa.Create();
+ dsa.ImportPkcs8PrivateKey(privateKeyBytes, out _);
+
+ var signature = dsa.SignData(unsignedJwtBytes, HashAlgorithmName.SHA256);
var signatureBase64 = Base64UrlEncode(signature);
return $"{unsignedJwtData}.{signatureBase64}";
}
-
+
private static string Base64UrlEncode(string str)
{
var bytes = Encoding.UTF8.GetBytes(str);
return Base64UrlEncode(bytes);
}
- private static string Base64UrlEncode(byte[] bytes) => Convert.ToBase64String(bytes);
+ private static string Base64UrlEncode(byte[] bytes) =>
+ Convert.ToBase64String(bytes)
+ .Replace('+', '-')
+ .Replace('/', '_')
+ .TrimEnd('=');
}
diff --git a/CorePush/CorePush.csproj b/CorePush/CorePush.csproj
index 8047d38..0486655 100644
--- a/CorePush/CorePush.csproj
+++ b/CorePush/CorePush.csproj
@@ -1,7 +1,7 @@
- net9.0
+ net10.0
true
Server Side library for sending ✅Web, ✅Android and ✅iOS Push Notifications
@@ -25,6 +25,15 @@
push-notifications android-push-notifications ios-push-notifications web-push web-push-notifications apn fcm firebase
+v5.0.0
+- Remove BouncyCastle dependency in favor of built-in .NET cryptography
+- Upgrade to .NET 10
+- Fix Base64URL encoding for JWT tokens (RFC 7617)
+- Fix null reference in APN error handling
+- Fix token cache key collision
+- Add device token validation
+- Propagate CancellationToken in Firebase sender
+
v4.3.0, v4.4.0
- Upgrade to Bouncycastle.Cryptography
- Other pkg version bumps
@@ -81,10 +90,6 @@ v3.0.0
-
-
-
-
diff --git a/CorePush/Firebase/FirebaseSender.cs b/CorePush/Firebase/FirebaseSender.cs
index da4e02c..5cad3f8 100644
--- a/CorePush/Firebase/FirebaseSender.cs
+++ b/CorePush/Firebase/FirebaseSender.cs
@@ -1,6 +1,6 @@
using System;
-using System.IO;
using System.Net.Http;
+using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -10,10 +10,6 @@
using CorePush.Serialization;
using CorePush.Utils;
-using Org.BouncyCastle.Crypto.Signers;
-using Org.BouncyCastle.Crypto;
-using Org.BouncyCastle.OpenSsl;
-
namespace CorePush.Firebase;
///
@@ -27,6 +23,7 @@ public class FirebaseSender : IFirebaseSender
private readonly HttpClient http;
private readonly FirebaseSettings settings;
private readonly IJsonSerializer serializer;
+ private readonly SemaphoreSlim tokenLock = new(1, 1);
private DateTime? firebaseTokenExpiration;
private FirebaseTokenResponse firebaseToken;
@@ -40,7 +37,7 @@ public class FirebaseSender : IFirebaseSender
public FirebaseSender(string serviceAccountFileJson, HttpClient http): this(serviceAccountFileJson, http, new DefaultCorePushJsonSerializer())
{
}
-
+
///
/// Initialize FirebaseSender
///
@@ -48,11 +45,11 @@ public class FirebaseSender : IFirebaseSender
/// The file would have a name like: myproject-12345-abc123123.json
/// HTTP client
/// Customized JSON serializer
- public FirebaseSender(string serviceAccountFileJson, HttpClient http, IJsonSerializer serializer)
+ public FirebaseSender(string serviceAccountFileJson, HttpClient http, IJsonSerializer serializer)
: this(serializer.Deserialize(serviceAccountFileJson), http, serializer)
{
}
-
+
///
/// Initialize FirebaseSender
///
@@ -99,11 +96,11 @@ public async Task SendAsync(object payload, CancellationToken cancel
var json = serializer.Serialize(payload);
using var message = new HttpRequestMessage(
- HttpMethod.Post,
+ HttpMethod.Post,
$"https://fcm.googleapis.com/v1/projects/{settings.ProjectId}/messages:send");
- var token = await GetJwtTokenAsync();
-
+ var token = await GetJwtTokenAsync(cancellationToken);
+
message.Headers.Add("Authorization", $"Bearer {token}");
message.Content = new StringContent(json, Encoding.UTF8, "application/json");
@@ -111,46 +108,59 @@ public async Task SendAsync(object payload, CancellationToken cancel
var responseString = await response.Content.ReadAsStringAsync(cancellationToken);
var firebaseResponse = serializer.Deserialize(responseString);
-
+
return new PushResult((int) response.StatusCode,
response.IsSuccessStatusCode,
firebaseResponse.Name ?? firebaseResponse.Error?.Message,
firebaseResponse.Error?.Status);
}
- private async Task GetJwtTokenAsync()
+ private async Task GetJwtTokenAsync(CancellationToken cancellationToken)
{
if (firebaseToken != null && firebaseTokenExpiration > DateTime.UtcNow)
{
return firebaseToken.AccessToken;
}
-
- using var message = new HttpRequestMessage(HttpMethod.Post, "https://oauth2.googleapis.com/token");
- using var form = new MultipartFormDataContent();
- var authToken = GetMasterToken();
- form.Add(new StringContent(authToken), "assertion");
- form.Add(new StringContent("urn:ietf:params:oauth:grant-type:jwt-bearer"), "grant_type");
- message.Content = form;
-
- using var response = await http.SendAsync(message);
- var content = await response.Content.ReadAsStringAsync();
-
- if (!response.IsSuccessStatusCode)
- {
- throw new HttpRequestException("Firebase error when creating JWT token: " + content);
- }
- firebaseToken = serializer.Deserialize(content);
- firebaseTokenExpiration = DateTime.UtcNow.AddSeconds(firebaseToken.ExpiresIn - 10);
+ await tokenLock.WaitAsync(cancellationToken);
+ try
+ {
+ if (firebaseToken != null && firebaseTokenExpiration > DateTime.UtcNow)
+ {
+ return firebaseToken.AccessToken;
+ }
+
+ using var message = new HttpRequestMessage(HttpMethod.Post, "https://oauth2.googleapis.com/token");
+ using var form = new MultipartFormDataContent();
+ var authToken = GetMasterToken();
+ form.Add(new StringContent(authToken), "assertion");
+ form.Add(new StringContent("urn:ietf:params:oauth:grant-type:jwt-bearer"), "grant_type");
+ message.Content = form;
+
+ using var response = await http.SendAsync(message, cancellationToken);
+ var content = await response.Content.ReadAsStringAsync(cancellationToken);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new HttpRequestException("Firebase error when creating JWT token: " + content);
+ }
+
+ firebaseToken = serializer.Deserialize(content);
+ firebaseTokenExpiration = DateTime.UtcNow.AddSeconds(firebaseToken.ExpiresIn - 10);
+
+ if (string.IsNullOrWhiteSpace(firebaseToken.AccessToken) || firebaseTokenExpiration < DateTime.UtcNow)
+ {
+ throw new InvalidOperationException("Couldn't deserialize firebase token response");
+ }
- if (string.IsNullOrWhiteSpace(firebaseToken.AccessToken) || firebaseTokenExpiration < DateTime.UtcNow)
+ return firebaseToken.AccessToken;
+ }
+ finally
{
- throw new InvalidOperationException("Couldn't deserialize firebase token response");
+ tokenLock.Release();
}
-
- return firebaseToken.AccessToken;
}
-
+
private string GetMasterToken()
{
var header = serializer.Serialize(new { alg = "RS256", typ = "JWT" });
@@ -162,36 +172,24 @@ private string GetMasterToken()
iat = CryptoHelper.GetEpochTimestamp(),
exp = CryptoHelper.GetEpochTimestamp() + 3600 /* has to be short lived */
});
-
- var headerBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(header));
- var payloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(payload));
+
+ var headerBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(header));
+ var payloadBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payload));
var unsignedJwtData = $"{headerBase64}.{payloadBase64}";
var unsignedJwtBytes = Encoding.UTF8.GetBytes(unsignedJwtData);
- var privateKey = ParsePkcs8PrivateKeyPem(settings.PrivateKey);
- var signer = new RsaDigestSigner(new Org.BouncyCastle.Crypto.Digests.Sha256Digest());
- signer.Init(true, privateKey);
- signer.BlockUpdate(unsignedJwtBytes, 0, unsignedJwtBytes.Length);
+ using var rsa = RSA.Create();
+ rsa.ImportFromPem(settings.PrivateKey);
- var signature = signer.GenerateSignature();
- var signatureBase64 = Convert.ToBase64String(signature);
+ var signature = rsa.SignData(unsignedJwtBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ var signatureBase64 = Base64UrlEncode(signature);
return $"{unsignedJwtData}.{signatureBase64}";
}
- private static AsymmetricKeyParameter ParsePkcs8PrivateKeyPem(string key)
- {
- using var keyReader = new StringReader(key);
- var pemReader = new PemReader(keyReader);
- var pemObject = pemReader.ReadObject();
-
- return pemObject switch
- {
- // PKCS#8 keys are typically returned as AsymmetricKeyParameter, not AsymmetricCipherKeyPair
- AsymmetricKeyParameter keyParameter => keyParameter,
- // handle case of key pair
- AsymmetricCipherKeyPair keyPair => keyPair.Private,
- _ => throw new InvalidOperationException("Invalid private key format.")
- };
- }
-}
\ No newline at end of file
+ private static string Base64UrlEncode(byte[] bytes) =>
+ Convert.ToBase64String(bytes)
+ .Replace('+', '-')
+ .Replace('/', '_')
+ .TrimEnd('=');
+}