Skip to content

Commit

Permalink
Merge pull request #12 from dlmelendez/rel/2.1
Browse files Browse the repository at this point in the history
v2.1
  • Loading branch information
dlmelendez authored Apr 27, 2023
2 parents c7e350a + a65984a commit d92bf9f
Show file tree
Hide file tree
Showing 9 changed files with 161 additions and 11 deletions.
11 changes: 10 additions & 1 deletion PayPalCheckoutSdk/PayPalCheckoutSdk.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFrameworks>net6.0;net7.0</TargetFrameworks>
<LangVersion>11.0</LangVersion>
<Version>2.0.1</Version>
<Version>2.1.0</Version>
<Nullable>enable</Nullable>
<Owners>David Melendez</Owners>
<RepositoryType>git</RepositoryType>
Expand All @@ -26,8 +26,17 @@

</PropertyGroup>

<ItemGroup>
<None Remove="Webhooks\DigiCertSHA2ExtendedValidationServerCA.crt" />
</ItemGroup>

<ItemGroup>
<EmbeddedResource Include="Webhooks\DigiCertSHA2ExtendedValidationServerCA.crt" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="ElCamino.PayPalHttp" Version="2.0.0" />
<PackageReference Include="Crc32.NET" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="2.2.0" />
</ItemGroup>

Expand Down
Binary file not shown.
109 changes: 109 additions & 0 deletions PayPalCheckoutSdk/Webhooks/VerifyWebhookEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading.Tasks;
using Force.Crc32;

namespace PayPalCheckoutSdk.Webhooks
{
public static class VerifyWebhookEvent
{
private const string WithRSAToken = "withRSA";

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);

// Get the cert from the cache and load the 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);

using var publicCertStream = typeof(Event).Assembly.GetManifestResourceStream("PayPalCheckoutSdk.Webhooks.DigiCertSHA2ExtendedValidationServerCA.crt");
byte[] resourceBytes = new byte[publicCertStream!.Length];
_ = await publicCertStream.ReadAsync(resourceBytes, 0, resourceBytes.Length);

X509Certificate2 publicLocalCertificate = new X509Certificate2(resourceBytes);
// Create and configure the X509Chain object
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 == publicLocalCertificate.Thumbprint);
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);

}
}
}
8 changes: 8 additions & 0 deletions PayPalCheckoutSdk/Webhooks/VerifyWebhookSignature.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,18 @@ public class VerifyWebhookSignature
/// <summary>
/// A webhook event notification.
/// </summary>
[Obsolete("Please use the WebhooEventRequestBody property instead with VerifyWebhookEvent.ValidateReceivedEventAsync")]
[DataMember(Name = "webhook_event", EmitDefaultValue = false)]
[JsonPropertyName("webhook_event")]
public Event? WebhookEvent { get; set; }

/// <summary>
/// Add the entire request body from the webhook post as a string
/// e.g. StreamReader.ReadToEnd() from the request body stream
/// </summary>
[JsonIgnore]
public string? WebhookEventRequestBody { get; set; }

/// <summary>
/// The ID of the webhook as configured in your Developer Portal account.
/// </summary>
Expand Down
3 changes: 2 additions & 1 deletion PayPalCheckoutSdk/Webhooks/VerifyWebhookSignatureRequest.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Mime;
Expand All @@ -7,6 +7,7 @@

namespace PayPalCheckoutSdk.Webhooks
{
[Obsolete("Replaced with VerifyWebhookEvent.ValidateReceivedEventAsync, to be removed in future version.")]
public class VerifyWebhookSignatureRequest : HttpRequest
{

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ namespace PayPalCheckoutSdk.Webhooks
/// <summary>
/// https://developer.paypal.com/docs/api/webhooks/v1/#definition-verify_webhook_signature_response
/// </summary>
[Obsolete("Replaced with VerifyWebhookEvent.ValidateReceivedEventAsync, to be removed in future version.")]
[DataContract]
public class VerifyWebhookSignatureResponse
{
Expand Down
2 changes: 1 addition & 1 deletion Samples/Samples.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<PackageVersion>2.0.1</PackageVersion>
<PackageVersion>2.1.0</PackageVersion>
</PropertyGroup>

<ItemGroup>
Expand Down
2 changes: 1 addition & 1 deletion Test/Test.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<PropertyGroup>
<TargetFrameworks>net6.0;net7.0</TargetFrameworks>
<PackageVersion>2.0.1</PackageVersion>
<PackageVersion>2.1.0</PackageVersion>
<IsPackable>false</IsPackable>
<UserSecretsId>4dbe0d9e-0d79-4e71-b5dd-44b90dbe3ee9</UserSecretsId>
</PropertyGroup>
Expand Down
36 changes: 29 additions & 7 deletions Test/Webhooks/EventTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,15 +144,15 @@ public async Task TestResendNegative()
public async Task TestVerifySignatureEventNegative()
{
VerifyWebhookSignatureRequest simulateRequest = new VerifyWebhookSignatureRequest();

var verifySignature = new VerifyWebhookSignature()
{
CertUrl = "https://example.com/65432123456-3467678768768",
AuthAlgo = Guid.NewGuid().ToString("N"),
TransmissionId = Guid.NewGuid().ToString("N"),
TransmissionSig = Guid.NewGuid().ToString("N"),
TransmissionTime = DateTime.UtcNow.ToPaypalString(),
WebhookId = "3RD03749806828333",
CertUrl = "https://api.sandbox.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-2d7ab011",
AuthAlgo = "SHA256withRSA",
TransmissionId = "d0a19f40-e46b-11ed-af6d-5d1995803275",
TransmissionSig = "Tg9131sVOAPVYn5XjQsR8C/tcOWuPkc//VkifmPX7TZD24\u002BkuRLIZ\u002BzfbMxkeuS0er1EzLHw4MRG83xkYoEGLe9QWD4nfvg/HIAvXDNgEZCG2BrPIwPaiFrA9G0SX22\u002BvpOiy4\u002BbWNrKAFZdt/gobEairdaqVe1unsCxCJQT6czTFiOBBAn85yDCSIhctk6RbEkprxjwTrgLDf1Cq41AgxZ72RwVuZlJHbMQF5Dl/cRQ9pU38I0HOq0DRXiiaJwrp7UJXkLRdu3ge4ivN3Th1Wq8D\u002BL/0xYrub9lFB0TKI2a7XBKlaua9aT7XrtuwZeI1cNz/jr0luz7K6JYdiZSlQ==",
TransmissionTime = "2023-04-26T19:52:03Z",
WebhookId = "3RY87287BU229431J",
WebhookEvent = new Event() { Id = Guid.NewGuid().ToString(), EventType = "PAYMENT.CAPTURE.COMPLETED" }
};
simulateRequest.RequestBody(verifySignature);
Expand All @@ -163,5 +163,27 @@ public async Task TestVerifySignatureEventNegative()

Assert.False(createResult.ValidSignature);
}

[Fact]
public async Task TestVerifySignatureEventCert()
{
var verifySignature = new VerifyWebhookSignature()
{
CertUrl = "https://api.sandbox.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-2d7ab011",
AuthAlgo = "SHA256withRSA",
TransmissionId = "d0a19f40-e46b-11ed-af6d-5d1995803275",
TransmissionSig = "Tg9131sVOAPVYn5XjQsR8C/tcOWuPkc//VkifmPX7TZD24\u002BkuRLIZ\u002BzfbMxkeuS0er1EzLHw4MRG83xkYoEGLe9QWD4nfvg/HIAvXDNgEZCG2BrPIwPaiFrA9G0SX22\u002BvpOiy4\u002BbWNrKAFZdt/gobEairdaqVe1unsCxCJQT6czTFiOBBAn85yDCSIhctk6RbEkprxjwTrgLDf1Cq41AgxZ72RwVuZlJHbMQF5Dl/cRQ9pU38I0HOq0DRXiiaJwrp7UJXkLRdu3ge4ivN3Th1Wq8D\u002BL/0xYrub9lFB0TKI2a7XBKlaua9aT7XrtuwZeI1cNz/jr0luz7K6JYdiZSlQ==",
TransmissionTime = "2023-04-26T19:52:03Z",
WebhookId = "3RY87287BU229431J",
WebhookEventRequestBody = "{\r\n \"create_time\": \"2023-04-26T19:51:57.324Z\",\r\n \"event_type\": \"BILLING.SUBSCRIPTION.ACTIVATED\",\r\n \"event_version\": \"1.0\",\r\n \"id\": \"WH-2GA799794C526164B-4HC631101W386110B\",\r\n \"links\": [\r\n {\r\n \"encType\": null,\r\n \"href\": \"https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-2GA799794C526164B-4HC631101W386110B\",\r\n \"mediaType\": null,\r\n \"method\": \"GET\",\r\n \"rel\": \"self\",\r\n \"title\": null\r\n },\r\n {\r\n \"encType\": null,\r\n \"href\": \"https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-2GA799794C526164B-4HC631101W386110B/resend\",\r\n \"mediaType\": null,\r\n \"method\": \"POST\",\r\n \"rel\": \"resend\",\r\n \"title\": null\r\n }\r\n ],\r\n \"resource\": {\r\n \"quantity\": \"1\",\r\n \"subscriber\": {\r\n \"email_address\": \"[email protected]\",\r\n \"payer_id\": \"P2DZ2LMXC5SY2\",\r\n \"name\": {\r\n \"given_name\": \"test\",\r\n \"surname\": \"buyer\"\r\n },\r\n \"shipping_address\": {\r\n \"address\": {\r\n \"address_line_1\": \"1 Main St\",\r\n \"admin_area_2\": \"San Jose\",\r\n \"admin_area_1\": \"CA\",\r\n \"postal_code\": \"95131\",\r\n \"country_code\": \"US\"\r\n }\r\n }\r\n },\r\n \"create_time\": \"2023-04-26T19:51:53Z\",\r\n \"plan_overridden\": false,\r\n \"shipping_amount\": {\r\n \"currency_code\": \"USD\",\r\n \"value\": \"0.0\"\r\n },\r\n \"start_time\": \"2023-04-26T19:51:38Z\",\r\n \"update_time\": \"2023-04-26T19:51:53Z\",\r\n \"billing_info\": {\r\n \"outstanding_balance\": {\r\n \"currency_code\": \"USD\",\r\n \"value\": \"0.0\"\r\n },\r\n \"cycle_executions\": [\r\n {\r\n \"tenure_type\": \"TRIAL\",\r\n \"sequence\": 1,\r\n \"cycles_completed\": 1,\r\n \"cycles_remaining\": 0,\r\n \"current_pricing_scheme_version\": 1,\r\n \"total_cycles\": 1\r\n },\r\n {\r\n \"tenure_type\": \"REGULAR\",\r\n \"sequence\": 2,\r\n \"cycles_completed\": 0,\r\n \"cycles_remaining\": 0,\r\n \"current_pricing_scheme_version\": 1,\r\n \"total_cycles\": 0\r\n }\r\n ],\r\n \"next_billing_time\": \"2023-05-10T10:00:00Z\",\r\n \"failed_payments_count\": 0\r\n },\r\n \"links\": [\r\n {\r\n \"href\": \"https://api.sandbox.paypal.com/v1/billing/subscriptions/I-B9YUUVSYXFX3/cancel\",\r\n \"rel\": \"cancel\",\r\n \"method\": \"POST\",\r\n \"encType\": \"application/json\"\r\n },\r\n {\r\n \"href\": \"https://api.sandbox.paypal.com/v1/billing/subscriptions/I-B9YUUVSYXFX3\",\r\n \"rel\": \"edit\",\r\n \"method\": \"PATCH\",\r\n \"encType\": \"application/json\"\r\n },\r\n {\r\n \"href\": \"https://api.sandbox.paypal.com/v1/billing/subscriptions/I-B9YUUVSYXFX3\",\r\n \"rel\": \"self\",\r\n \"method\": \"GET\",\r\n \"encType\": \"application/json\"\r\n },\r\n {\r\n \"href\": \"https://api.sandbox.paypal.com/v1/billing/subscriptions/I-B9YUUVSYXFX3/suspend\",\r\n \"rel\": \"suspend\",\r\n \"method\": \"POST\",\r\n \"encType\": \"application/json\"\r\n },\r\n {\r\n \"href\": \"https://api.sandbox.paypal.com/v1/billing/subscriptions/I-B9YUUVSYXFX3/capture\",\r\n \"rel\": \"capture\",\r\n \"method\": \"POST\",\r\n \"encType\": \"application/json\"\r\n }\r\n ],\r\n \"id\": \"I-B9YUUVSYXFX3\",\r\n \"plan_id\": \"P-11V96330AW144370PMBGAOEA\",\r\n \"status\": \"ACTIVE\",\r\n \"status_update_time\": \"2023-04-26T19:51:53Z\"\r\n },\r\n \"resource_type\": \"subscription\",\r\n \"resource_version\": \"2.0\",\r\n \"summary\": \"Subscription activated\"\r\n }",
};

bool verified = await VerifyWebhookEvent.ValidateReceivedEventAsync(verifySignature);
//
// Unfortunately, we it is too difficult to setup a positive test.
// We are mainly checking for any exceptions during validation
Assert.False(verified);

}
}
}

0 comments on commit d92bf9f

Please sign in to comment.