Files
ScadaBridge/src/ScadaLink.InboundAPI/ApiKeyValidator.cs
T

115 lines
4.3 KiB
C#

using System.Security.Cryptography;
using System.Text;
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Interfaces.Repositories;
namespace ScadaLink.InboundAPI;
/// <summary>
/// WP-1: Validates API keys from X-API-Key header.
/// Checks that the key exists, is enabled, and is approved for the requested method.
/// </summary>
public class ApiKeyValidator
{
private readonly IInboundApiRepository _repository;
public ApiKeyValidator(IInboundApiRepository repository)
{
_repository = repository;
}
/// <summary>
/// Validates an API key for a given method.
/// Returns (isValid, apiKey, statusCode, errorMessage).
/// </summary>
public async Task<ApiKeyValidationResult> ValidateAsync(
string? apiKeyValue,
string methodName,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(apiKeyValue))
{
return ApiKeyValidationResult.Unauthorized("Missing X-API-Key header");
}
// InboundAPI-003: do NOT resolve the key with a secret-equality lookup
// (GetApiKeyByValueAsync translates to a SQL "WHERE KeyValue = @secret" early-exit
// comparison — a timing side-channel). Fetch all keys and match the secret
// in-process with a constant-time comparison so neither match position nor
// secret length is observable to a network attacker.
var apiKey = FindKeyConstantTime(
await _repository.GetAllApiKeysAsync(cancellationToken),
apiKeyValue);
if (apiKey == null || !apiKey.IsEnabled)
{
return ApiKeyValidationResult.Unauthorized("Invalid or disabled API key");
}
var method = await _repository.GetMethodByNameAsync(methodName, cancellationToken);
if (method == null)
{
return ApiKeyValidationResult.NotFound($"Method '{methodName}' not found");
}
// Check if this key is approved for the method
var approvedKeys = await _repository.GetApprovedKeysForMethodAsync(method.Id, cancellationToken);
var isApproved = approvedKeys.Any(k => k.Id == apiKey.Id);
if (!isApproved)
{
return ApiKeyValidationResult.Forbidden("API key not approved for this method");
}
return ApiKeyValidationResult.Valid(apiKey, method);
}
/// <summary>
/// InboundAPI-003: Finds the key whose value matches <paramref name="candidate"/>
/// using <see cref="CryptographicOperations.FixedTimeEquals"/> over the UTF-8 bytes.
/// Every candidate row is compared so that the running time does not depend on the
/// match position; length mismatches return false without leaking length timing.
/// </summary>
private static ApiKey? FindKeyConstantTime(IEnumerable<ApiKey> keys, string candidate)
{
var candidateBytes = Encoding.UTF8.GetBytes(candidate);
ApiKey? match = null;
foreach (var key in keys)
{
var keyBytes = Encoding.UTF8.GetBytes(key.KeyValue);
if (CryptographicOperations.FixedTimeEquals(candidateBytes, keyBytes))
{
// Do not break — continuing keeps the loop's timing independent of
// where (or whether) a match is found.
match = key;
}
}
return match;
}
}
/// <summary>
/// Result of API key validation.
/// </summary>
public class ApiKeyValidationResult
{
public bool IsValid { get; private init; }
public int StatusCode { get; private init; }
public string? ErrorMessage { get; private init; }
public ApiKey? ApiKey { get; private init; }
public ApiMethod? Method { get; private init; }
public static ApiKeyValidationResult Valid(ApiKey apiKey, ApiMethod method) =>
new() { IsValid = true, StatusCode = 200, ApiKey = apiKey, Method = method };
public static ApiKeyValidationResult Unauthorized(string message) =>
new() { IsValid = false, StatusCode = 401, ErrorMessage = message };
public static ApiKeyValidationResult Forbidden(string message) =>
new() { IsValid = false, StatusCode = 403, ErrorMessage = message };
public static ApiKeyValidationResult NotFound(string message) =>
new() { IsValid = false, StatusCode = 400, ErrorMessage = message };
}