Skip to content

Commit d65ab74

Browse files
author
jamesw383
committed
Release MakePay 1.7.2 DPoP registration fix
1 parent bb8368f commit d65ab74

7 files changed

Lines changed: 238 additions & 19 deletions

File tree

BTCPayServer.Plugins.MakePay.Tests/BTCPayServer.Plugins.MakePay.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
<ItemGroup>
1111
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
12+
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
1213
<PackageReference Include="xunit" Version="2.9.2" />
1314
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
1415
<PrivateAssets>all</PrivateAssets>
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
#nullable enable
2+
using System;
3+
using System.Collections.Generic;
4+
using System.Net;
5+
using System.Net.Http;
6+
using System.Security.Cryptography;
7+
using System.Text;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
using BTCPayServer.Plugins.MakePay.PaymentHandler;
11+
using BTCPayServer.Plugins.MakePay.Services;
12+
using Microsoft.Extensions.Logging.Abstractions;
13+
using Newtonsoft.Json.Linq;
14+
using Xunit;
15+
16+
namespace BTCPayServer.Plugins.MakePay.Tests;
17+
18+
public class MakePayDpopRegistrationTests
19+
{
20+
[Fact]
21+
public void DisconnectKeepsTheRegisteredKeyForAProvableReconnect()
22+
{
23+
var config = new MakePayPaymentMethodConfig
24+
{
25+
AccessToken = "access",
26+
ClientId = "client",
27+
DpopJkt = "registered-thumbprint",
28+
DpopPrivateKeyPem = "registered-private-key",
29+
RefreshToken = "refresh",
30+
WebhookSecret = "webhook"
31+
};
32+
33+
config.ClearConnectionForReconnect();
34+
35+
Assert.Null(config.AccessToken);
36+
Assert.Null(config.ClientId);
37+
Assert.Null(config.RefreshToken);
38+
Assert.Null(config.WebhookSecret);
39+
Assert.Equal("registered-thumbprint", config.DpopJkt);
40+
Assert.Equal("registered-private-key", config.DpopPrivateKeyPem);
41+
}
42+
43+
[Fact]
44+
public async Task NativeRegistrationIncludesProofBoundToSubmittedKeyAndEndpoint()
45+
{
46+
var keyPair = MakePayDpopService.GenerateKeyPair();
47+
var handler = new RecordingHandler();
48+
var client = new MakePayApiClient(
49+
new HttpClient(handler),
50+
NullLogger<MakePayApiClient>.Instance);
51+
var config = new MakePayPaymentMethodConfig
52+
{
53+
ApiBaseUrl = "https://www.makecrypto.io"
54+
};
55+
56+
await client.RegisterNativeInstallation(
57+
config,
58+
"https://merchant.example",
59+
"https://merchant.example/plugins/store/makepay/oauth/callback",
60+
keyPair.Thumbprint,
61+
keyPair.PrivateKeyPem,
62+
null,
63+
null,
64+
"2.3.9");
65+
66+
Assert.NotNull(handler.Request);
67+
Assert.Equal(HttpMethod.Post, handler.Request!.Method);
68+
Assert.Equal(
69+
"https://www.makecrypto.io/oauth/native/installations",
70+
handler.Request.RequestUri?.ToString());
71+
Assert.True(handler.Request.Headers.TryGetValues("DPoP", out var values));
72+
73+
var proof = Assert.Single(values);
74+
var verified = VerifyProof(proof);
75+
Assert.Equal("dpop+jwt", verified.Header["typ"]?.Value<string>());
76+
Assert.Equal("ES256", verified.Header["alg"]?.Value<string>());
77+
Assert.Equal("POST", verified.Payload["htm"]?.Value<string>());
78+
Assert.Equal(
79+
"https://www.makecrypto.io/oauth/native/installations",
80+
verified.Payload["htu"]?.Value<string>());
81+
Assert.False(string.IsNullOrWhiteSpace(verified.Payload["jti"]?.Value<string>()));
82+
Assert.InRange(
83+
verified.Payload["iat"]?.Value<long>() ?? 0,
84+
DateTimeOffset.UtcNow.AddMinutes(-1).ToUnixTimeSeconds(),
85+
DateTimeOffset.UtcNow.AddMinutes(1).ToUnixTimeSeconds());
86+
Assert.Equal(keyPair.Thumbprint, Thumbprint((JObject)verified.Header["jwk"]!));
87+
}
88+
89+
[Fact]
90+
public async Task NativeRegistrationProvesBothKeysDuringRotation()
91+
{
92+
var nextKey = MakePayDpopService.GenerateKeyPair();
93+
var previousKey = MakePayDpopService.GenerateKeyPair();
94+
var handler = new RecordingHandler();
95+
var client = new MakePayApiClient(
96+
new HttpClient(handler),
97+
NullLogger<MakePayApiClient>.Instance);
98+
99+
await client.RegisterNativeInstallation(
100+
new MakePayPaymentMethodConfig(),
101+
"https://merchant.example",
102+
"https://merchant.example/plugins/store/makepay/oauth/callback",
103+
nextKey.Thumbprint,
104+
nextKey.PrivateKeyPem,
105+
previousKey.Thumbprint,
106+
previousKey.PrivateKeyPem,
107+
"2.3.9");
108+
109+
Assert.NotNull(handler.Request);
110+
Assert.True(handler.Request!.Headers.TryGetValues("DPoP", out var nextValues));
111+
Assert.True(handler.Request.Headers.TryGetValues("DPoP-Previous", out var previousValues));
112+
Assert.Equal(
113+
nextKey.Thumbprint,
114+
Thumbprint((JObject)VerifyProof(Assert.Single(nextValues)).Header["jwk"]!));
115+
Assert.Equal(
116+
previousKey.Thumbprint,
117+
Thumbprint((JObject)VerifyProof(Assert.Single(previousValues)).Header["jwk"]!));
118+
}
119+
120+
private static (JObject Header, JObject Payload) VerifyProof(string proof)
121+
{
122+
var parts = proof.Split('.');
123+
Assert.Equal(3, parts.Length);
124+
var header = JObject.Parse(
125+
Encoding.UTF8.GetString(Base64UrlDecode(parts[0])));
126+
var payload = JObject.Parse(
127+
Encoding.UTF8.GetString(Base64UrlDecode(parts[1])));
128+
var jwk = (JObject)header["jwk"]!;
129+
var parameters = new ECParameters
130+
{
131+
Curve = ECCurve.NamedCurves.nistP256,
132+
Q = new ECPoint
133+
{
134+
X = Base64UrlDecode(jwk["x"]!.Value<string>()!),
135+
Y = Base64UrlDecode(jwk["y"]!.Value<string>()!)
136+
}
137+
};
138+
using var publicKey = ECDsa.Create(parameters);
139+
Assert.True(publicKey.VerifyData(
140+
Encoding.ASCII.GetBytes(parts[0] + "." + parts[1]),
141+
Base64UrlDecode(parts[2]),
142+
HashAlgorithmName.SHA256,
143+
DSASignatureFormat.IeeeP1363FixedFieldConcatenation));
144+
return (header, payload);
145+
}
146+
147+
private static string Thumbprint(JObject publicJwk)
148+
{
149+
var canonical = new JObject
150+
{
151+
["crv"] = publicJwk["crv"],
152+
["kty"] = publicJwk["kty"],
153+
["x"] = publicJwk["x"],
154+
["y"] = publicJwk["y"]
155+
}.ToString(Newtonsoft.Json.Formatting.None);
156+
return Base64UrlEncode(SHA256.HashData(Encoding.UTF8.GetBytes(canonical)));
157+
}
158+
159+
private static string Base64UrlEncode(byte[] bytes)
160+
{
161+
return Convert.ToBase64String(bytes)
162+
.TrimEnd('=')
163+
.Replace('+', '-')
164+
.Replace('/', '_');
165+
}
166+
167+
private static byte[] Base64UrlDecode(string value)
168+
{
169+
var padded = value.Replace('-', '+').Replace('_', '/');
170+
padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '=');
171+
return Convert.FromBase64String(padded);
172+
}
173+
174+
private sealed class RecordingHandler : HttpMessageHandler
175+
{
176+
public HttpRequestMessage? Request { get; private set; }
177+
178+
protected override Task<HttpResponseMessage> SendAsync(
179+
HttpRequestMessage request,
180+
CancellationToken cancellationToken)
181+
{
182+
Request = request;
183+
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Created)
184+
{
185+
Content = new StringContent(
186+
"""{"client_id":"mco_app_test"}""",
187+
Encoding.UTF8,
188+
"application/json")
189+
});
190+
}
191+
}
192+
}

BTCPayServer.Plugins.MakePay/BTCPayServer.Plugins.MakePay.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
<PropertyGroup>
1010
<Product>Accept more than 90+ coins and chains - MakePay</Product>
1111
<Description>Accept 90+ altcoins with Makepay’s fully decentralized BTCPay plugin. Customers pay in their preferred coin; merchants receive their chosen asset via instant conversion.</Description>
12-
<Version>1.7.0</Version>
12+
<Version>1.7.2</Version>
1313
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
1414
</PropertyGroup>
1515

BTCPayServer.Plugins.MakePay/Controllers/MakePayController.cs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -250,15 +250,13 @@ private async Task<IActionResult> SaveSettings(
250250
catch (Exception ex)
251251
{
252252
config.ClientId = null;
253-
config.DpopPrivateKeyPem = null;
254-
config.DpopJkt = null;
255253
config.LastError = SafeError(ex.Message);
256254
await SaveConfig(store, config.ClearOAuthState());
257255
SetStatus(StatusMessageModel.StatusSeverity.Error, "MakePay connection failed. Check the MakePay settings and try again.");
258256
return RedirectToAction(nameof(General), new { storeId });
259257
}
260258
case "disconnect":
261-
config.ClearConnection();
259+
config.ClearConnectionForReconnect();
262260
await SaveConfig(store, config);
263261
SetStatus(StatusMessageModel.StatusSeverity.Success, "MakePay disconnected.");
264262
return RedirectToAction(nameof(General), new { storeId });
@@ -530,7 +528,15 @@ public async Task<IActionResult> OAuthCallback(
530528

531529
private async Task<IActionResult> Connect(StoreData store, MakePayPaymentMethodConfig config)
532530
{
533-
var dpop = MakePayDpopService.GenerateKeyPair();
531+
var previousDpopPrivateKeyPem = config.DpopPrivateKeyPem;
532+
var previousDpopJkt = config.DpopJkt;
533+
var dpop = !string.IsNullOrWhiteSpace(previousDpopPrivateKeyPem) &&
534+
!string.IsNullOrWhiteSpace(previousDpopJkt)
535+
? new MakePayDpopService.DpopKeyPair(
536+
previousDpopPrivateKeyPem,
537+
string.Empty,
538+
previousDpopJkt)
539+
: MakePayDpopService.GenerateKeyPair();
534540
var siteUrl = config.NormalizedSiteUrl();
535541
if (string.IsNullOrWhiteSpace(siteUrl))
536542
{
@@ -554,6 +560,9 @@ private async Task<IActionResult> Connect(StoreData store, MakePayPaymentMethodC
554560
siteUrl,
555561
redirectUri,
556562
dpop.Thumbprint,
563+
dpop.PrivateKeyPem,
564+
previousDpopJkt,
565+
previousDpopPrivateKeyPem,
557566
null);
558567
config.ClientId = registration["client_id"]?.Value<string>() ??
559568
throw new InvalidOperationException("MakePay did not return a client id.");

BTCPayServer.Plugins.MakePay/MakePayPlugin.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ namespace BTCPayServer.Plugins.MakePay;
1010

1111
public class MakePayPlugin : BaseBTCPayServerPlugin
1212
{
13-
public const string PluginVersion = "1.7.0";
13+
public const string PluginVersion = "1.7.2";
1414
public static readonly PaymentMethodId MakePayPaymentMethodId = new("MAKEPAY");
1515

1616
public override IBTCPayServerPlugin.PluginDependency[] Dependencies { get; } =

BTCPayServer.Plugins.MakePay/PaymentHandler/MakePayPaymentMethodConfig.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,16 @@ public MakePayPaymentMethodConfig ClearConnection()
214214
return ClearOAuthState();
215215
}
216216

217+
public MakePayPaymentMethodConfig ClearConnectionForReconnect()
218+
{
219+
var dpopPrivateKeyPem = DpopPrivateKeyPem;
220+
var dpopJkt = DpopJkt;
221+
ClearConnection();
222+
DpopPrivateKeyPem = dpopPrivateKeyPem;
223+
DpopJkt = dpopJkt;
224+
return this;
225+
}
226+
217227
public static string? SerializeSettlementPriorities(
218228
IReadOnlyCollection<MakePaySettlementPriority> priorities)
219229
{

BTCPayServer.Plugins.MakePay/Services/MakePayApiClient.cs

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ public async Task<JObject> RegisterNativeInstallation(
3131
string siteUrl,
3232
string redirectUri,
3333
string dpopJkt,
34+
string dpopPrivateKeyPem,
35+
string? previousDpopJkt,
36+
string? previousDpopPrivateKeyPem,
3437
string? btcpayVersion,
3538
CancellationToken cancellationToken = default)
3639
{
@@ -45,7 +48,23 @@ public async Task<JObject> RegisterNativeInstallation(
4548
["btcpayServerVersion"] = btcpayVersion
4649
};
4750

48-
return await SendJson(url, body, cancellationToken);
51+
var request = new HttpRequestMessage(HttpMethod.Post, url)
52+
{
53+
Content = new StringContent(body.ToString(Formatting.None), Encoding.UTF8, "application/json")
54+
};
55+
request.Headers.TryAddWithoutValidation(
56+
"DPoP",
57+
MakePayDpopService.CreateProof(dpopPrivateKeyPem, "POST", url));
58+
if (!string.IsNullOrWhiteSpace(previousDpopPrivateKeyPem) &&
59+
!string.IsNullOrWhiteSpace(previousDpopJkt) &&
60+
!string.Equals(previousDpopJkt, dpopJkt, StringComparison.Ordinal))
61+
{
62+
request.Headers.TryAddWithoutValidation(
63+
"DPoP-Previous",
64+
MakePayDpopService.CreateProof(previousDpopPrivateKeyPem, "POST", url));
65+
}
66+
67+
return await Send(request, cancellationToken);
4968
}
5069

5170
public async Task<JObject> ExchangeCode(
@@ -424,18 +443,6 @@ private async Task EnsureAccessToken(
424443
await RefreshAccessToken(config, cancellationToken);
425444
}
426445

427-
private async Task<JObject> SendJson(
428-
string url,
429-
JObject body,
430-
CancellationToken cancellationToken)
431-
{
432-
var request = new HttpRequestMessage(HttpMethod.Post, url)
433-
{
434-
Content = new StringContent(body.ToString(Formatting.None), Encoding.UTF8, "application/json")
435-
};
436-
return await Send(request, cancellationToken);
437-
}
438-
439446
private async Task<JObject> Send(
440447
HttpRequestMessage request,
441448
CancellationToken cancellationToken)

0 commit comments

Comments
 (0)