Skip to content

Commit 86cf8f4

Browse files
committed
Add .NET Standard 2.0 support and improve compatibility
1 parent dc42d46 commit 86cf8f4

7 files changed

Lines changed: 257 additions & 11 deletions

File tree

.github/workflows/stale.yml

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
name: Stale Items
2+
on:
3+
schedule:
4+
- cron: "0 6 * * 0"
5+
6+
workflow_dispatch:
7+
8+
jobs:
9+
stale:
10+
name: Mark and Close Stale Items
11+
runs-on: ubuntu-latest
12+
permissions:
13+
issues: write
14+
pull-requests: write
15+
steps:
16+
- uses: actions/stale@v9
17+
with:
18+
repo-token: ${{ secrets.GITHUB_TOKEN }}
19+
20+
days-before-stale: 365
21+
days-before-close: 45
22+
23+
# Issue configuration
24+
stale-issue-label: "stale"
25+
close-issue-label: "closed:stale"
26+
exempt-issue-labels: "pinned,security,enhancement,bug,backlog,epic"
27+
28+
stale-issue-message: |
29+
## ⏰ Stale Issue
30+
31+
This issue has had no activity for 1 year.
32+
It will be closed in 45 days unless there is new activity.
33+
To keep it open, comment or remove the `stale` label.
34+
35+
close-issue-message: |
36+
## 🔒 Closed: Inactive Issue
37+
38+
Closed after 45 days of inactivity.
39+
To reopen, comment with a reason and a maintainer will review.
40+
41+
# PR configuration
42+
stale-pr-label: "stale"
43+
close-pr-label: "closed:stale"
44+
exempt-pr-labels: "pinned,work-in-progress,ready-for-review"
45+
46+
stale-pr-message: |
47+
## ⏰ Stale Pull Request
48+
49+
No activity for 1 year. Will close in 45 days unless updated.
50+
To keep open, push commits, comment, or remove the `stale` label.
51+
52+
close-pr-message: |
53+
## 🔒 Closed: Inactive PR
54+
55+
Closed after 45 days of inactivity.
56+
To continue, reopen or submit a new PR and reference this one.

src/HashGate.AspNetCore/HmacAuthenticationShared.cs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,26 @@ public static string CreateStringToSign(
100100
// 2 for the two '\n' literals
101101
int totalLength = methodLength + pathAndQueryLength + headerValuesLength + separatorLength + 2;
102102

103+
#if NETSTANDARD2_0
104+
var stringBuilder = new StringBuilder(totalLength);
105+
106+
stringBuilder
107+
.Append(method.ToUpperInvariant())
108+
.Append('\n')
109+
.Append(pathAndQuery)
110+
.Append('\n');
111+
112+
// Write header values with semicolons
113+
for (int i = 0; i < headerValues.Count; i++)
114+
{
115+
if (i > 0)
116+
stringBuilder.Append(';');
117+
118+
stringBuilder.Append(headerValues[i]);
119+
}
120+
121+
return stringBuilder.ToString();
122+
#else
103123
return string.Create(totalLength, (method, pathAndQuery, headerValues), (span, state) =>
104124
{
105125
int pos = 0;
@@ -129,6 +149,7 @@ public static string CreateStringToSign(
129149
pos += header.Length;
130150
}
131151
});
152+
#endif
132153
}
133154

134155
/// <summary>
@@ -146,6 +167,12 @@ public static string GenerateSignature(
146167
var secretBytes = Encoding.UTF8.GetBytes(secretKey);
147168
var dataBytes = Encoding.UTF8.GetBytes(stringToSign);
148169

170+
#if NETSTANDARD2_0
171+
// Use traditional approach for .NET Standard 2.0
172+
using var hmac = new HMACSHA256(secretBytes);
173+
var hash = hmac.ComputeHash(dataBytes);
174+
return Convert.ToBase64String(hash);
175+
#else
149176
// Compute HMACSHA256
150177
Span<byte> hash = stackalloc byte[32];
151178
using var hmac = new HMACSHA256(secretBytes);
@@ -163,6 +190,7 @@ public static string GenerateSignature(
163190
return new string(base64[..charsWritten]);
164191

165192
return Convert.ToBase64String(hash);
193+
#endif
166194
}
167195

168196
/// <summary>
@@ -183,6 +211,34 @@ public static string GenerateAuthorizationHeader(
183211
const string signedHeadersPrefix = "&SignedHeaders=";
184212
const string signaturePrefix = "&Signature=";
185213

214+
#if NETSTANDARD2_0
215+
var stringBuilder = new StringBuilder();
216+
217+
// Write scheme
218+
stringBuilder.Append(scheme);
219+
220+
// Write client prefix and client
221+
stringBuilder.Append(clientPrefix);
222+
stringBuilder.Append(client);
223+
224+
// Write signedHeaders prefix
225+
stringBuilder.Append(signedHeadersPrefix);
226+
227+
// Write signedHeaders (semicolon separated)
228+
for (int i = 0; i < signedHeaders.Count; i++)
229+
{
230+
if (i > 0)
231+
stringBuilder.Append(';');
232+
233+
stringBuilder.Append(signedHeaders[i]);
234+
}
235+
236+
// Write signature prefix and signature
237+
stringBuilder.Append(signaturePrefix);
238+
stringBuilder.Append(signature);
239+
240+
return stringBuilder.ToString();
241+
#else
186242
// Calculate signedHeaders string and its length
187243
int signedHeadersCount = signedHeaders.Count;
188244
int signedHeadersLength = 0;
@@ -241,6 +297,7 @@ public static string GenerateAuthorizationHeader(
241297
state.signature.AsSpan().CopyTo(span.Slice(pos, state.signature.Length));
242298
pos += state.signature.Length;
243299
});
300+
#endif
244301
}
245302

246303
/// <summary>
@@ -262,7 +319,16 @@ public static bool FixedTimeEquals(
262319
if (leftBytes.Length != rightBytes.Length)
263320
return false;
264321

322+
#if NETSTANDARD2_0
323+
// Manual constant-time comparison for .NET Standard 2.0
324+
int result = 0;
325+
for (int i = 0; i < leftBytes.Length; i++)
326+
result |= leftBytes[i] ^ rightBytes[i];
327+
328+
return result == 0;
329+
#else
265330
// Use FixedTimeEquals for constant-time comparison
266331
return CryptographicOperations.FixedTimeEquals(leftBytes, rightBytes);
332+
#endif
267333
}
268334
}

src/HashGate.HttpClient/DependencyInjectionExtensions.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ public static IServiceCollection AddHmacAuthentication(
6767
this IServiceCollection services,
6868
Action<HmacAuthenticationOptions>? configure = null)
6969
{
70-
ArgumentNullException.ThrowIfNull(services);
70+
if (services == null)
71+
throw new ArgumentNullException(nameof(services));
7172

7273
services
7374
.AddOptions<HmacAuthenticationOptions>()

src/HashGate.HttpClient/HashGate.HttpClient.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4-
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
4+
<TargetFrameworks>netstandard2.0;net8.0;net9.0</TargetFrameworks>
55
<ImplicitUsings>enable</ImplicitUsings>
66
<Nullable>enable</Nullable>
77
<Description>HTTP client library for HMAC authentication in ASP.NET Core applications. Provides easy integration for making authenticated HTTP requests using HMAC-based authentication schemes.</Description>
88
<PackageTags>aspnetcore;hmac;authentication;http;client;security</PackageTags>
99
</PropertyGroup>
10-
10+
1111
<ItemGroup>
1212
<Compile Include="..\HashGate.AspNetCore\HmacAuthenticationShared.cs" Link="HmacAuthenticationShared.cs" />
1313
</ItemGroup>

src/HashGate.HttpClient/HmacAuthenticationOptions.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ public class HmacAuthenticationOptions
6464
/// </list>
6565
/// </remarks>
6666
[Required]
67-
public required string Client { get; set; }
67+
public string Client { get; set; } = null!;
6868

6969
/// <summary>
7070
/// Gets or sets the secret key used for HMAC-SHA256 signature generation.
@@ -89,7 +89,7 @@ public class HmacAuthenticationOptions
8989
/// </para>
9090
/// </remarks>
9191
[Required]
92-
public required string Secret { get; set; }
92+
public string Secret { get; set; } = null!;
9393

9494
/// <summary>
9595
/// Gets or sets the list of HTTP header names that should be included in the HMAC signature calculation.

src/HashGate.HttpClient/HttpRequestMessageExtensions.cs

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,20 @@ public static async Task AddHmacAuthentication(
5757
string secret,
5858
IReadOnlyList<string>? signedHeaders = null)
5959
{
60-
ArgumentNullException.ThrowIfNull(request);
61-
ArgumentException.ThrowIfNullOrWhiteSpace(client);
62-
ArgumentException.ThrowIfNullOrWhiteSpace(secret);
60+
if (request is null)
61+
throw new ArgumentNullException(nameof(request));
62+
63+
if (client is null)
64+
throw new ArgumentNullException(nameof(client));
65+
66+
if (string.IsNullOrWhiteSpace(client))
67+
throw new ArgumentException("Client cannot be empty or whitespace.", nameof(client));
68+
69+
if (secret is null)
70+
throw new ArgumentNullException(nameof(secret));
71+
72+
if (string.IsNullOrWhiteSpace(secret))
73+
throw new ArgumentException("Secret cannot be empty or whitespace.", nameof(secret));
6374

6475
// ensure required headers are present
6576
if (signedHeaders == null)
@@ -126,8 +137,10 @@ public static Task AddHmacAuthentication(
126137
this HttpRequestMessage request,
127138
HmacAuthenticationOptions options)
128139
{
129-
ArgumentNullException.ThrowIfNull(request);
130-
ArgumentNullException.ThrowIfNull(options);
140+
if (request is null)
141+
throw new ArgumentNullException(nameof(request));
142+
if (options is null)
143+
throw new ArgumentNullException(nameof(options));
131144

132145
return request.AddHmacAuthentication(
133146
client: options.Client,
@@ -174,8 +187,14 @@ public static async Task<string> GenerateContentHash(this HttpRequestMessage req
174187
if (request.Content == null)
175188
return HmacAuthenticationShared.EmptyContentHash;
176189

177-
byte[] bodyBytes = await request.Content.ReadAsByteArrayAsync();
190+
var bodyBytes = await request.Content.ReadAsByteArrayAsync();
191+
192+
#if NETSTANDARD2_0
193+
using var sha256 = SHA256.Create();
194+
var hashBytes = sha256.ComputeHash(bodyBytes);
195+
#else
178196
var hashBytes = SHA256.HashData(bodyBytes);
197+
#endif
179198

180199
// consume the content stream, need to recreate it
181200
var originalContent = new ByteArrayContent(bodyBytes);
@@ -185,10 +204,12 @@ public static async Task<string> GenerateContentHash(this HttpRequestMessage req
185204
// Restore content with headers
186205
request.Content = originalContent;
187206

207+
#if !NETSTANDARD2_0
188208
// 32 bytes SHA256 -> 44 chars base64
189209
Span<char> base64 = stackalloc char[44];
190210
if (Convert.TryToBase64Chars(hashBytes, base64, out int charsWritten))
191211
return new string(base64[..charsWritten]);
212+
#endif
192213

193214
// if stackalloc is not large enough (should not happen for SHA256)
194215
return Convert.ToBase64String(hashBytes);
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
namespace HashGate.HttpClient.Tests;
2+
3+
public class HmacAuthenticationSharedTests
4+
{
5+
[Fact]
6+
public void CreateStringToSign_CreatesExpectedFormat()
7+
{
8+
var method = "get";
9+
var pathAndQuery = "/api/resource?x=1";
10+
var headers = new[] { "value1", "value2" };
11+
var result = HmacAuthenticationShared.CreateStringToSign(method, pathAndQuery, headers);
12+
13+
Assert.Equal("GET\n/api/resource?x=1\nvalue1;value2", result);
14+
}
15+
16+
[Fact]
17+
public void CreateStringToSign_EmptyHeaders_CreatesExpectedFormat()
18+
{
19+
var method = "post";
20+
var pathAndQuery = "/api/empty";
21+
var headers = Array.Empty<string>();
22+
var result = HmacAuthenticationShared.CreateStringToSign(method, pathAndQuery, headers);
23+
24+
Assert.Equal("POST\n/api/empty\n", result);
25+
}
26+
27+
[Fact]
28+
public void CreateStringToSign_SingleHeader_CreatesExpectedFormat()
29+
{
30+
var method = "put";
31+
var pathAndQuery = "/api/one";
32+
var headers = new[] { "onlyone" };
33+
var result = HmacAuthenticationShared.CreateStringToSign(method, pathAndQuery, headers);
34+
35+
Assert.Equal("PUT\n/api/one\nonlyone", result);
36+
}
37+
38+
[Theory]
39+
[InlineData("", "", true)]
40+
[InlineData(" ", " ", true)]
41+
[InlineData("abc", "", false)]
42+
[InlineData("", "abc", false)]
43+
public void FixedTimeEquals_EmptyAndWhitespaceCases(string left, string right, bool expected)
44+
{
45+
Assert.Equal(expected, HmacAuthenticationShared.FixedTimeEquals(left, right));
46+
}
47+
48+
[Theory]
49+
[InlineData("abc", "abc", true)]
50+
[InlineData("abc", "def", false)]
51+
[InlineData("abc", "abcd", false)]
52+
[InlineData("abc", "abc ", false)]
53+
public void FixedTimeEquals_ComparesCorrectly(string left, string right, bool expected)
54+
{
55+
Assert.Equal(expected, HmacAuthenticationShared.FixedTimeEquals(left, right));
56+
}
57+
58+
59+
[Fact]
60+
public void GenerateAuthorizationHeader_CreatesExpectedHeader()
61+
{
62+
var client = "abc123";
63+
var signedHeaders = new[] { "host", "date" };
64+
var signature = "xyz789";
65+
var header = HmacAuthenticationShared.GenerateAuthorizationHeader(client, signedHeaders, signature);
66+
67+
Assert.Equal("HMAC Client=abc123&SignedHeaders=host;date&Signature=xyz789", header);
68+
}
69+
70+
[Fact]
71+
public void GenerateAuthorizationHeader_EmptyHeaders_CreatesExpectedHeader()
72+
{
73+
var client = "empty";
74+
var signedHeaders = Array.Empty<string>();
75+
var signature = "sig";
76+
var header = HmacAuthenticationShared.GenerateAuthorizationHeader(client, signedHeaders, signature);
77+
78+
Assert.Equal("HMAC Client=empty&SignedHeaders=&Signature=sig", header);
79+
}
80+
81+
[Fact]
82+
public void GenerateSignature_EmptyInputs_ReturnsExpectedBase64()
83+
{
84+
var signature = HmacAuthenticationShared.GenerateSignature("", "");
85+
Assert.Equal(44, signature.Length);
86+
Assert.True(Convert.TryFromBase64String(signature, new Span<byte>(new byte[32]), out _));
87+
}
88+
89+
[Fact]
90+
public void GenerateSignature_ReturnsExpectedBase64()
91+
{
92+
var stringToSign = "GET\n/api/resource?x=1\nvalue1;value2";
93+
var secretKey = "mysecret";
94+
var signature = HmacAuthenticationShared.GenerateSignature(stringToSign, secretKey);
95+
96+
// Validate base64 length for SHA256
97+
Assert.Equal(44, signature.Length);
98+
99+
// Should be valid base64
100+
Assert.True(Convert.TryFromBase64String(signature, new Span<byte>(new byte[32]), out _));
101+
}
102+
}

0 commit comments

Comments
 (0)