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('='); +}