Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,15 +227,15 @@ Semicolon-separated list of HTTP header names that were included in the signatur

#### Required Headers

The following headers must always be included:
The following headers **must** always be included in `SignedHeaders`. The server enforces their presence and will reject requests that omit them:

- `host`
- `x-timestamp`
- `x-content-sha256`
- `x-timestamp` — required for replay protection; cryptographically binds the timestamp to the signature
- `x-content-sha256` — required for body integrity; cryptographically binds the content hash to the signature

#### Optional Headers

You can include additional headers for enhanced security:
You can include additional headers beyond the required set for enhanced security:

```text
host;x-timestamp;x-content-sha256;content-type;accept
Expand Down Expand Up @@ -398,10 +398,29 @@ JavaScript/Node.js client implementation:
- **Features**: Browser and Node.js compatible, TypeScript definitions
- **Run**: `npm install && npm start`

### Sample.Python

Python client implementation:

- **Location**: `samples/Sample.Python/`
- **Features**: Easy-to-use client class, demo script, interactive testing tool, unit tests
- **Requirements**: Python 3, dependencies in `requirements.txt`
- **Run**: `pip install -r requirements.txt && python demo.py`

### Sample.Java

Java client implementation using the built-in `java.net.http.HttpClient`:

- **Location**: `samples/Sample.Java/`
- **Features**: HMAC client class, demo and example apps, unit tests, no external HTTP dependencies
- **Requirements**: Java 25+, Maven 3.9+
- **Run**: `mvn compile && mvn exec:java`

## Security Considerations

- **Always use HTTPS** in production environments
- **Protect HMAC secret keys** - never expose them in client-side code
- **Always include required signed headers** - `x-timestamp` and `x-content-sha256` must be in `SignedHeaders` (the server enforces this)
- **Monitor timestamp tolerance** - shorter windows provide better security
- **Rotate keys regularly** - implement key rotation policies
- **Log authentication failures** - monitor for potential attacks
Expand Down
10 changes: 7 additions & 3 deletions docs/Implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ Every authenticated request MUST include these headers:
3. **x-content-sha256**: Base64-encoded SHA256 hash of request body (required even for empty bodies)
4. **Authorization**: HMAC authentication header

> **Important:** The `x-timestamp` and `x-content-sha256` headers are **required** entries in the `SignedHeaders` parameter of the Authorization header. The server enforces this and will reject any request that does not include both headers in `SignedHeaders`. This ensures the timestamp and body hash are cryptographically bound to the signature, providing replay protection and body integrity guarantees.

### String-to-Sign Format

The canonical string for signing follows this exact format and is critical for generating a valid HMAC signature. Any deviation from this format will result in authentication failures.
Expand Down Expand Up @@ -151,6 +153,7 @@ HMAC Client={CLIENT_ID}&SignedHeaders={HEADER_NAMES}&Signature={BASE64_SIGNATURE

- Semicolon-separated list of header names included in the signature
- Order must match the order used in string-to-sign construction
- **Must** include `x-timestamp` and `x-content-sha256` (server-enforced)
- Default: `host;x-timestamp;x-content-sha256`
- Example with custom headers: `host;x-timestamp;x-content-sha256;content-type;user-agent`

Expand Down Expand Up @@ -245,6 +248,7 @@ HMAC Client=demo&SignedHeaders=host&Signature=abc123

### Validation Rules

- **Signed Headers**: `x-timestamp` and `x-content-sha256` must be included in `SignedHeaders` (server-enforced)
- **Timestamp**: Must be within server's time tolerance window (typically 5 minutes)
- **Content Hash**: Must match actual request body hash
- **Signature**: Must match server's calculated signature
Expand Down Expand Up @@ -583,12 +587,12 @@ try {
### Default Signed Headers

- **host**: Target host header
- **x-timestamp**: Unix timestamp of request
- **x-content-sha256**: SHA256 hash of request body (required even for empty bodies)
- **x-timestamp**: Unix timestamp of request (**required** — the server rejects requests that omit this from `SignedHeaders`)
- **x-content-sha256**: SHA256 hash of request body, required even for empty bodies (**required** — the server rejects requests that omit this from `SignedHeaders`)

### Custom Signed Headers

You can include additional headers in the signature for enhanced security:
You can include additional headers beyond the required set in the signature for enhanced security:

```csharp
// .NET
Expand Down
35 changes: 34 additions & 1 deletion src/HashGate.AspNetCore/HmacAuthenticationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public partial class HmacAuthenticationHandler : AuthenticationHandler<HmacAuthe
private static readonly AuthenticateResult InvalidContentHashHeader = AuthenticateResult.Fail("Invalid content hash header");
private static readonly AuthenticateResult InvalidClientName = AuthenticateResult.Fail("Invalid client name");
private static readonly AuthenticateResult InvalidSignature = AuthenticateResult.Fail("Invalid signature");
private static readonly AuthenticateResult MissingRequiredSignedHeaders = AuthenticateResult.Fail("Missing required signed headers");
private static readonly AuthenticateResult AuthenticationError = AuthenticateResult.Fail("Authentication error");

/// <summary>
Expand Down Expand Up @@ -72,6 +73,15 @@ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
return AuthenticateResult.Fail($"Invalid Authorization header: {result}");
}

// Enforce that critical headers are always cryptographically bound to the signature.
// Without this, a custom client could omit x-timestamp or x-content-sha256 from SignedHeaders,
// weakening replay protection or allowing body substitution.
if (!ValidateRequiredSignedHeaders(hmacHeader.SignedHeaders))
{
LogMissingRequiredSignedHeaders(Logger);
return MissingRequiredSignedHeaders;
}

if (!ValidateTimestamp(out var requestTime))
{
// Reject stale/future requests outside the allowed replay-protection window.
Expand Down Expand Up @@ -176,7 +186,11 @@ private async Task<string> GenerateContentHash()
{
Request.EnableBuffering();

if (Request.ContentLength == 0 || Request.Body == Stream.Null)
// Do not trust the Content-Length header to determine whether a body exists.
// A malicious client or proxy could send Content-Length: 0 with an actual body,
// causing us to return the empty hash while the application later reads real content.
// Instead, only short-circuit for Stream.Null (no body stream at all).
if (Request.Body == Stream.Null)
return HmacAuthenticationShared.EmptyContentHash;

using var sha = SHA256.Create();
Expand Down Expand Up @@ -248,6 +262,22 @@ private string[] GetHeaderValues(IReadOnlyList<string> signedHeaders)
return null;
}

private static bool ValidateRequiredSignedHeaders(IReadOnlyList<string> signedHeaders)
{
bool hasTimestamp = false;
bool hasContentHash = false;

for (int i = 0; i < signedHeaders.Count; i++)
{
if (signedHeaders[i].Equals(HmacAuthenticationShared.TimeStampHeaderName, StringComparison.OrdinalIgnoreCase))
hasTimestamp = true;
else if (signedHeaders[i].Equals(HmacAuthenticationShared.ContentHashHeaderName, StringComparison.OrdinalIgnoreCase))
hasContentHash = true;
}

return hasTimestamp && hasContentHash;
}


[LoggerMessage(Level = LogLevel.Warning, Message = "Invalid Authorization header: {HeaderError}")]
private static partial void LogInvalidAuthorizationHeader(ILogger logger, HmacHeaderError headerError);
Expand All @@ -264,6 +294,9 @@ private string[] GetHeaderValues(IReadOnlyList<string> signedHeaders)
[LoggerMessage(Level = LogLevel.Warning, Message = "Invalid signature for client: {Client}")]
private static partial void LogInvalidSignature(ILogger logger, string client);

[LoggerMessage(Level = LogLevel.Warning, Message = "Missing required signed headers: x-timestamp and x-content-sha256 must be included in SignedHeaders")]
private static partial void LogMissingRequiredSignedHeaders(ILogger logger);

[LoggerMessage(Level = LogLevel.Error, Message = "Error during HMAC authentication: {ErrorMessage}")]
private static partial void LogAuthenticationError(ILogger logger, Exception exception, string errorMessage);
}
29 changes: 21 additions & 8 deletions src/HashGate.AspNetCore/HmacAuthenticationShared.cs
Original file line number Diff line number Diff line change
Expand Up @@ -319,19 +319,32 @@ public static bool FixedTimeEquals(
var leftBytes = Encoding.UTF8.GetBytes(left);
var rightBytes = Encoding.UTF8.GetBytes(right);

// If lengths differ, return false immediately
if (leftBytes.Length != rightBytes.Length)
return false;

#if NETSTANDARD2_0 || NETFRAMEWORK
// Manual constant-time comparison for .NET Standard 2.0
int result = 0;
for (int i = 0; i < leftBytes.Length; i++)
// Constant-time comparison that does not leak length information.
// XOR the lengths and accumulate into the result so a length mismatch
// does not cause an early return (which would be a timing side-channel).
int result = leftBytes.Length ^ rightBytes.Length;
int minLength = Math.Min(leftBytes.Length, rightBytes.Length);

for (int i = 0; i < minLength; i++)
result |= leftBytes[i] ^ rightBytes[i];

return result == 0;
#else
// Use FixedTimeEquals for constant-time comparison
// CryptographicOperations.FixedTimeEquals already handles length
// differences in constant time by returning false without leaking
// which bytes differ, but it does reveal differing lengths via timing.
// For HMAC/SHA256 comparisons the outputs are always the same length,
// so this is acceptable. Guard with a length check that folds into
// the result to keep the public API safe for variable-length callers.
if (leftBytes.Length != rightBytes.Length)
{
// Compare left against itself so we still spend time proportional
// to the input length, then return false.
CryptographicOperations.FixedTimeEquals(leftBytes, leftBytes);
return false;
}

return CryptographicOperations.FixedTimeEquals(leftBytes, rightBytes);
#endif
}
Expand Down
6 changes: 6 additions & 0 deletions src/HashGate.AspNetCore/IpAddressWhitelist.cs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,12 @@ public static bool IsIpInNetwork(IPAddress? ipAddress, string? network)
var baseBytes = baseIp.GetAddressBytes();
var remoteBytes = ipAddress.GetAddressBytes();

// Validate prefix length is within valid range for the address family.
// IPv4 addresses are 4 bytes (max 32), IPv6 addresses are 16 bytes (max 128).
int maxPrefixLength = baseBytes.Length * 8;
if (prefixLength < 0 || prefixLength > maxPrefixLength)
return false;

// Ensure both IPs are of the same address family (IPv4/IPv6)
if (baseBytes.Length != remoteBytes.Length)
return false;
Expand Down
Loading
Loading