using System.Security.Cryptography; using System.Text; using ScadaLink.Commons.Entities.InboundApi; using ScadaLink.Commons.Interfaces.Repositories; namespace ScadaLink.InboundAPI; /// /// WP-1: Validates API keys from X-API-Key header. /// Checks that the key exists, is enabled, and is approved for the requested method. /// public class ApiKeyValidator { private readonly IInboundApiRepository _repository; public ApiKeyValidator(IInboundApiRepository repository) { _repository = repository; } /// /// Validates an API key for a given method. /// Returns (isValid, apiKey, statusCode, errorMessage). /// public async Task 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); } /// /// InboundAPI-003: Finds the key whose value matches /// using 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. /// private static ApiKey? FindKeyConstantTime(IEnumerable 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; } } /// /// Result of API key validation. /// 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 }; }