fix(inbound-api): resolve InboundAPI-001/003/005 — concurrent handler cache, constant-time API key compare, script trust-model enforcement

This commit is contained in:
Joseph Doherty
2026-05-16 19:47:17 -04:00
parent d30ded7e72
commit 6f4efdfa2e
6 changed files with 393 additions and 28 deletions
+35 -1
View File
@@ -1,3 +1,5 @@
using System.Security.Cryptography;
using System.Text;
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Interfaces.Repositories;
@@ -30,7 +32,14 @@ public class ApiKeyValidator
return ApiKeyValidationResult.Unauthorized("Missing X-API-Key header");
}
var apiKey = await _repository.GetApiKeyByValueAsync(apiKeyValue, cancellationToken);
// 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");
@@ -53,6 +62,31 @@ public class ApiKeyValidator
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>