Issue #6: implement api key hashing and verification
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public sealed record ApiKeyIdentity(
|
||||
string KeyId,
|
||||
string KeyPrefix,
|
||||
string DisplayName,
|
||||
IReadOnlySet<string> Scopes);
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public sealed class ApiKeyParser : IApiKeyParser
|
||||
{
|
||||
private const string BearerPrefix = "Bearer ";
|
||||
private const string TokenPrefix = "mxgw_";
|
||||
|
||||
public bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey)
|
||||
{
|
||||
apiKey = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(authorizationHeader)
|
||||
|| !authorizationHeader.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string token = authorizationHeader[BearerPrefix.Length..].Trim();
|
||||
|
||||
if (!token.StartsWith(TokenPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string keyPayload = token[TokenPrefix.Length..];
|
||||
int separatorIndex = keyPayload.IndexOf('_', StringComparison.Ordinal);
|
||||
|
||||
if (separatorIndex <= 0 || separatorIndex == keyPayload.Length - 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string keyId = keyPayload[..separatorIndex];
|
||||
string secret = keyPayload[(separatorIndex + 1)..];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(keyId) || string.IsNullOrWhiteSpace(secret))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
apiKey = new ParsedApiKey(keyId, secret);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public sealed class ApiKeyPepperUnavailableException(string pepperSecretName)
|
||||
: InvalidOperationException($"API key pepper secret '{pepperSecretName}' is not configured.");
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MxGateway.Server.Configuration;
|
||||
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public sealed class ApiKeySecretHasher(
|
||||
IConfiguration configuration,
|
||||
IOptions<GatewayOptions> options) : IApiKeySecretHasher
|
||||
{
|
||||
public byte[] HashSecret(string secret)
|
||||
{
|
||||
string pepper = GetPepper();
|
||||
byte[] pepperBytes = Encoding.UTF8.GetBytes(pepper);
|
||||
byte[] secretBytes = Encoding.UTF8.GetBytes(secret);
|
||||
|
||||
using HMACSHA256 hmac = new(pepperBytes);
|
||||
|
||||
return hmac.ComputeHash(secretBytes);
|
||||
}
|
||||
|
||||
private string GetPepper()
|
||||
{
|
||||
string pepperSecretName = options.Value.Authentication.PepperSecretName;
|
||||
string? pepper = configuration[pepperSecretName];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pepper))
|
||||
{
|
||||
throw new ApiKeyPepperUnavailableException(pepperSecretName);
|
||||
}
|
||||
|
||||
return pepper;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public enum ApiKeyVerificationFailure
|
||||
{
|
||||
None,
|
||||
MissingOrMalformedCredentials,
|
||||
PepperUnavailable,
|
||||
KeyNotFound,
|
||||
KeyRevoked,
|
||||
SecretMismatch
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public sealed record ApiKeyVerificationResult(
|
||||
bool Succeeded,
|
||||
ApiKeyIdentity? Identity,
|
||||
ApiKeyVerificationFailure Failure)
|
||||
{
|
||||
public static ApiKeyVerificationResult Success(ApiKeyIdentity identity)
|
||||
{
|
||||
return new ApiKeyVerificationResult(
|
||||
Succeeded: true,
|
||||
Identity: identity,
|
||||
Failure: ApiKeyVerificationFailure.None);
|
||||
}
|
||||
|
||||
public static ApiKeyVerificationResult Fail(ApiKeyVerificationFailure failure)
|
||||
{
|
||||
return new ApiKeyVerificationResult(
|
||||
Succeeded: false,
|
||||
Identity: null,
|
||||
Failure: failure);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public sealed class ApiKeyVerifier(
|
||||
IApiKeyParser parser,
|
||||
IApiKeySecretHasher hasher,
|
||||
IApiKeyStore keyStore) : IApiKeyVerifier
|
||||
{
|
||||
public async Task<ApiKeyVerificationResult> VerifyAsync(
|
||||
string? authorizationHeader,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!parser.TryParseAuthorizationHeader(authorizationHeader, out ParsedApiKey? parsedKey)
|
||||
|| parsedKey is null)
|
||||
{
|
||||
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.MissingOrMalformedCredentials);
|
||||
}
|
||||
|
||||
ApiKeyRecord? storedKey = await keyStore.FindByKeyIdAsync(parsedKey.KeyId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (storedKey is null)
|
||||
{
|
||||
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.KeyNotFound);
|
||||
}
|
||||
|
||||
if (storedKey.RevokedUtc is not null)
|
||||
{
|
||||
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.KeyRevoked);
|
||||
}
|
||||
|
||||
byte[] presentedHash;
|
||||
try
|
||||
{
|
||||
presentedHash = hasher.HashSecret(parsedKey.Secret);
|
||||
}
|
||||
catch (ApiKeyPepperUnavailableException)
|
||||
{
|
||||
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.PepperUnavailable);
|
||||
}
|
||||
|
||||
if (!CryptographicOperations.FixedTimeEquals(presentedHash, storedKey.SecretHash))
|
||||
{
|
||||
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.SecretMismatch);
|
||||
}
|
||||
|
||||
await keyStore.MarkKeyUsedAsync(storedKey.KeyId, DateTimeOffset.UtcNow, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return ApiKeyVerificationResult.Success(new ApiKeyIdentity(
|
||||
KeyId: storedKey.KeyId,
|
||||
KeyPrefix: storedKey.KeyPrefix,
|
||||
DisplayName: storedKey.DisplayName,
|
||||
Scopes: storedKey.Scopes));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ public static class AuthStoreServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddSqliteAuthStore(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IApiKeyParser, ApiKeyParser>();
|
||||
services.AddSingleton<IApiKeySecretHasher, ApiKeySecretHasher>();
|
||||
services.AddSingleton<IApiKeyVerifier, ApiKeyVerifier>();
|
||||
services.AddSingleton<AuthSqliteConnectionFactory>();
|
||||
services.AddSingleton<IAuthStoreMigrator, SqliteAuthStoreMigrator>();
|
||||
services.AddSingleton<IApiKeyStore, SqliteApiKeyStore>();
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public interface IApiKeyParser
|
||||
{
|
||||
bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public interface IApiKeySecretHasher
|
||||
{
|
||||
byte[] HashSecret(string secret);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public interface IApiKeyVerifier
|
||||
{
|
||||
Task<ApiKeyVerificationResult> VerifyAsync(
|
||||
string? authorizationHeader,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public sealed record ParsedApiKey(string KeyId, string Secret);
|
||||
Reference in New Issue
Block a user