
PayPal has deprecated their .NET Checkout SDK. However by forking the code and making some improvements; their is new life for the once retired project. In this article, we do a quick summary of the new features added to the Elcamino.PayPalCheckoutSDK and how to verify a PayPal webhook request.
New Features
v2.0 - v2.2
- Small perf increase in VerifyWebhookEvent.ValidateReceivedEventAsync
- Adding VerifyWebhookEvent.ValidateReceivedEventAsync to validate webhook events client side
- #10 fix for token expiration tracking in AccessToken
- Adding .net6 and .net7 support
- Adding System.Text.Json serialization options
- Dropping .netstandard support
- ElCamino.PayPalHttp 2.0 (HttpClient performance improvements)
v1.9
- .netstandard support
- Adding ElCamino.PayPalHttp 1.9 regex performance improvements using .net 7 code generation.
- Full support for Paypal Webhooks https://developer.paypal.com/docs/api/webhooks/v1/
- Full billing support via products, plans and subscriptions https://developer.paypal.com/docs/api/subscriptions/v1/
- Code cleanup, mostly member formatting
Let's verify a webhook
Add a reference to nuget Elcamino.PayPalCheckoutSDK
dotnet add package ElCamino.PayPalCheckoutSdk --version 2.2.0
or
<PackageReference Include="ElCamino.PayPalCheckoutSdk" Version="2.2.0" />
Here is a sample MVC action that would accept a Post method, validate the payload
public async Task<IActionResult> Post()
{
using StreamReader reader = new StreamReader(Request.Body);
string requestWebEventBody = await reader.ReadToEndAsync();
var verifySignature = new VerifyWebhookSignature()
{
CertUrl = Request.Headers[PayPalCheckoutSdk.HeaderNameConstants.VerifySignature.CertUrl],
AuthAlgo = Request.Headers[PayPalCheckoutSdk.HeaderNameConstants.VerifySignature.AuthAlgo],
TransmissionId = Request.Headers[PayPalCheckoutSdk.HeaderNameConstants.VerifySignature.TransmissionId],
TransmissionSig = Request.Headers[PayPalCheckoutSdk.HeaderNameConstants.VerifySignature.TransmissionSig],
TransmissionTime = Request.Headers[PayPalCheckoutSdk.HeaderNameConstants.VerifySignature.TransmissionTime],
WebhookId = "<your webhook id>",
WebhookEventRequestBody = requestWebEventBody
};
bool isValid = await VerifyWebhookEvent.ValidateReceivedEventAsync(verifySignature);
//Do something based on webhook data being validated or not
//Further process the payload based on https://developer.paypal.com/docs/api/webhooks/v1/
return NoContent();
What does VerifyWebhookEvent.ValidateReceivedEventAsync(verifySignature) do?
- Makes sure all of the signature attributes are available
- Calculates a CRC32 checksum using the request body.
- Generates the expected signature.
- Loads the remote trusted certificate from CertUrl.
- Get the local certificate thumbprint from the embedded resource. This certificate was distributed with an earlier version of the PayPal SDK.
- Create and configure the X509Chain object to validate remote certificate chain.
- Check remote certificate (from CertUrl) contains the local thumbprint in the certificate chain.
- Finally, verify the received signature matches the expected signature.
public static class VerifyWebhookEvent
{
private const string WithRSAToken = "withRSA";
private static string? _publicLocalCertificateThumbprint;
public static async Task<bool> ValidateReceivedEventAsync(VerifyWebhookSignature verifySignature)
{
if (string.IsNullOrWhiteSpace(verifySignature.TransmissionTime))
{
throw new ArgumentNullException(nameof(verifySignature), $"{nameof(verifySignature.TransmissionTime)} is null or empty");
}
if (string.IsNullOrWhiteSpace(verifySignature.TransmissionId))
{
throw new ArgumentNullException(nameof(verifySignature), $"{nameof(verifySignature.TransmissionId)} is null or empty");
}
if (string.IsNullOrWhiteSpace(verifySignature.TransmissionSig))
{
throw new ArgumentNullException(nameof(verifySignature), $"{nameof(verifySignature.TransmissionSig)} is null or empty");
}
if (string.IsNullOrWhiteSpace(verifySignature.AuthAlgo))
{
throw new ArgumentNullException(nameof(verifySignature), $"{nameof(verifySignature.AuthAlgo)} is null or empty");
}
if (string.IsNullOrWhiteSpace(verifySignature.CertUrl))
{
throw new ArgumentNullException(nameof(verifySignature), $"{nameof(verifySignature.CertUrl)} is null or empty");
}
if (string.IsNullOrWhiteSpace(verifySignature.WebhookEventRequestBody))
{
throw new ArgumentNullException(nameof(verifySignature), $"{nameof(verifySignature.WebhookEventRequestBody)} is null or empty");
}
if (string.IsNullOrWhiteSpace(verifySignature.WebhookId))
{
throw new ArgumentNullException(nameof(verifySignature), $"{nameof(verifySignature.WebhookId)} is null or empty");
}
// Convert the provided auth alrogithm header into a known hash alrogithm name.
if (!verifySignature.AuthAlgo.EndsWith(WithRSAToken))
{
throw new ArgumentException($"{nameof(verifySignature.AuthAlgo)} must end with {WithRSAToken}", nameof(verifySignature));
}
string hashAlgorithm = verifySignature.AuthAlgo.Replace(WithRSAToken, "");
string? oid = CryptoConfig.MapNameToOID(hashAlgorithm);
if (string.IsNullOrWhiteSpace(oid))
{
throw new Exception($"Invalid OID from {hashAlgorithm}");
}
// Calculate a CRC32 checksum using the request body.
byte[] bytes = Encoding.UTF8.GetBytes(verifySignature.WebhookEventRequestBody);
uint crc32 = Crc32Algorithm.Compute(bytes);
// Generate the expected signature.
var expectedSignature = string.Format("{0}|{1}|{2}|{3}", verifySignature.TransmissionId, verifySignature.TransmissionTime, verifySignature.WebhookId, crc32);
var expectedSignatureBytes = Encoding.UTF8.GetBytes(expectedSignature);
// Load the remote trusted certificate.
using System.Net.Http.HttpClient httpClient = new System.Net.Http.HttpClient();
byte[] certificateBytes = await httpClient.GetByteArrayAsync(verifySignature.CertUrl);
X509Certificate2Collection remoteCertificateCollection = new X509Certificate2Collection();
remoteCertificateCollection.Import(certificateBytes);
//Get the local cert thumbprint from the embedded resource
if (string.IsNullOrWhiteSpace(_publicLocalCertificateThumbprint))
{
using var publicCertStream = typeof(Event).Assembly.GetManifestResourceStream("PayPalCheckoutSdk.Webhooks.DigiCertSHA2ExtendedValidationServerCA.crt");
byte[] resourceBytes = new byte[publicCertStream!.Length];
_ = await publicCertStream.ReadAsync(resourceBytes);
X509Certificate2 publicLocalCertificate = new X509Certificate2(resourceBytes);
_publicLocalCertificateThumbprint = publicLocalCertificate.Thumbprint;
}
// Create and configure the X509Chain object to validate remote certificate
using X509Chain chain = new X509Chain();
chain.ChainPolicy.RevocationMode = X509RevocationMode.Online;
chain.ChainPolicy.RevocationFlag = X509RevocationFlag.EntireChain;
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
bool validateRemoteCert = chain.Build(remoteCertificateCollection[0]);
if (!validateRemoteCert)
{
throw new Exception($"Invalid chain on remote certificate {verifySignature.CertUrl}");
}
// Validate the certificate chain.
bool validateChain = chain.ChainElements.Any(a => a.Certificate.Thumbprint == (_publicLocalCertificateThumbprint?? string.Empty));
if (!validateChain)
{
throw new Exception($"Invalid remote certificate, public key not found in chain {verifySignature.CertUrl}");
}
// Verify the received signature matches the expected signature.
using var rsa = remoteCertificateCollection[0].GetRSAPublicKey() ?? throw new Exception($"GetRSAPublicKey() failed on remote certificate, {verifySignature.CertUrl}");
var signatureBytes = Convert.FromBase64String(verifySignature.TransmissionSig);
return rsa.VerifyData(expectedSignatureBytes, signatureBytes, HashAlgorithmName.FromOid(oid), RSASignaturePadding.Pkcs1);
}
}
Warning
This VerifyWebhookEvent.ValidateReceivedEventAsync() method is only available .NET 6 and higher.
Summary
This was a quick summary of a great .NET PayPal resource to accelerate your integration with the latest versions. Also, we took a look a validation a webhook payload with only few lines of code with a breakdown of the verify signature process.
Open a discussion Tweet