115 lines
4.3 KiB
C#
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 };
|
|
}
|