fix(configuration-database): resolve ConfigurationDatabase-012 — store inbound-API keys as HMAC-SHA256 hashes
Inbound-API bearer credentials are no longer persisted in plaintext. ApiKey now holds a KeyHash (peppered HMAC-SHA256); the key is shown once at creation and only its hash is stored. Lookup and validation hash the presented candidate. Cross-module: Commons (ApiKey, ApiKeyHasher), ConfigurationDatabase (mapping + HashApiKeyValue migration), InboundAPI (ApiKeyValidator), ManagementService (key creation), CentralUI (ApiKeys.razor). Existing keys must be re-issued.
This commit is contained in:
@@ -2,6 +2,7 @@ using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using ScadaLink.Commons.Entities.InboundApi;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Types.InboundApi;
|
||||
|
||||
namespace ScadaLink.InboundAPI;
|
||||
|
||||
@@ -12,14 +13,24 @@ namespace ScadaLink.InboundAPI;
|
||||
public class ApiKeyValidator
|
||||
{
|
||||
private readonly IInboundApiRepository _repository;
|
||||
private readonly IApiKeyHasher _hasher;
|
||||
|
||||
// InboundAPI-011: the single message used for both "method not found" and
|
||||
// "key not approved" so the two outcomes are indistinguishable to the caller.
|
||||
private const string NotApprovedMessage = "API key not approved for this method";
|
||||
|
||||
public ApiKeyValidator(IInboundApiRepository repository)
|
||||
/// <param name="repository">Inbound-API data access.</param>
|
||||
/// <param name="hasher">
|
||||
/// ConfigurationDatabase-012: hashes the presented candidate key with the same
|
||||
/// HMAC-SHA256 pepper used at key creation, so authentication compares hashes —
|
||||
/// the database never holds a plaintext credential. Defaults to the unpeppered
|
||||
/// <see cref="ApiKeyHasher.Default"/>; production wiring injects the configured,
|
||||
/// peppered hasher.
|
||||
/// </param>
|
||||
public ApiKeyValidator(IInboundApiRepository repository, IApiKeyHasher? hasher = null)
|
||||
{
|
||||
_repository = repository;
|
||||
_hasher = hasher ?? ApiKeyHasher.Default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -37,13 +48,17 @@ public class ApiKeyValidator
|
||||
}
|
||||
|
||||
// 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.
|
||||
// (GetApiKeyByValueAsync translates to a SQL "WHERE KeyHash = @hash" early-exit
|
||||
// comparison — a timing side-channel). Fetch all keys and match in-process
|
||||
// with a constant-time comparison so neither match position nor length is
|
||||
// observable to a network attacker.
|
||||
// ConfigurationDatabase-012: the database stores only the HMAC hash of each
|
||||
// key, so the presented candidate is hashed with the same pepper and the
|
||||
// comparison runs over the resulting hashes — never over plaintext.
|
||||
var candidateHash = _hasher.Hash(apiKeyValue);
|
||||
var apiKey = FindKeyConstantTime(
|
||||
await _repository.GetAllApiKeysAsync(cancellationToken),
|
||||
apiKeyValue);
|
||||
candidateHash);
|
||||
if (apiKey == null || !apiKey.IsEnabled)
|
||||
{
|
||||
return ApiKeyValidationResult.Unauthorized("Invalid or disabled API key");
|
||||
@@ -73,19 +88,21 @@ public class ApiKeyValidator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// InboundAPI-003: Finds the key whose value matches <paramref name="candidate"/>
|
||||
/// using <see cref="CryptographicOperations.FixedTimeEquals"/> over the UTF-8 bytes.
|
||||
/// InboundAPI-003 / ConfigurationDatabase-012: Finds the key whose stored
|
||||
/// <see cref="ApiKey.KeyHash"/> matches <paramref name="candidateHash"/> — the
|
||||
/// HMAC hash of the presented key — 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)
|
||||
private static ApiKey? FindKeyConstantTime(IEnumerable<ApiKey> keys, string candidateHash)
|
||||
{
|
||||
var candidateBytes = Encoding.UTF8.GetBytes(candidate);
|
||||
var candidateBytes = Encoding.UTF8.GetBytes(candidateHash);
|
||||
ApiKey? match = null;
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
var keyBytes = Encoding.UTF8.GetBytes(key.KeyValue);
|
||||
var keyBytes = Encoding.UTF8.GetBytes(key.KeyHash);
|
||||
if (CryptographicOperations.FixedTimeEquals(candidateBytes, keyBytes))
|
||||
{
|
||||
// Do not break — continuing keeps the loop's timing independent of
|
||||
|
||||
@@ -17,4 +17,18 @@ public class InboundApiOptions
|
||||
/// bounds per-request allocations.
|
||||
/// </summary>
|
||||
public long MaxRequestBodyBytes { get; set; } = DefaultMaxRequestBodyBytes;
|
||||
|
||||
/// <summary>
|
||||
/// ConfigurationDatabase-012: server-side HMAC pepper used to hash inbound-API
|
||||
/// bearer credentials. API keys are persisted as a deterministic keyed hash, never
|
||||
/// as plaintext; this pepper is the HMAC key that binds every hash to this
|
||||
/// deployment, so a stolen configuration database is not directly exploitable.
|
||||
/// <para>
|
||||
/// This is a secret: supply a strong, random value via configuration or a secret
|
||||
/// store, never hard-coded. It must be present and at least
|
||||
/// <see cref="ScadaLink.Commons.Types.InboundApi.ApiKeyHasher.MinimumPepperLength"/>
|
||||
/// characters — <c>AddInboundAPI</c> fails fast otherwise.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public string ApiKeyPepper { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ScadaLink.Commons.Types.InboundApi;
|
||||
|
||||
namespace ScadaLink.InboundAPI;
|
||||
|
||||
@@ -10,6 +12,18 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<InboundScriptExecutor>();
|
||||
services.AddScoped<RouteHelper>();
|
||||
|
||||
// ConfigurationDatabase-012: API keys are persisted as a deterministic HMAC
|
||||
// hash, never as plaintext. The hasher is keyed with a server-side pepper
|
||||
// bound from configuration (InboundApiOptions.ApiKeyPepper). Constructing
|
||||
// ApiKeyHasher throws if the pepper is missing or weak — so a misconfigured
|
||||
// deployment fails fast the first time the hasher is resolved rather than
|
||||
// silently hashing with no pepper.
|
||||
services.AddSingleton<IApiKeyHasher>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<InboundApiOptions>>().Value;
|
||||
return new ApiKeyHasher(options.ApiKeyPepper);
|
||||
});
|
||||
|
||||
// InboundAPI-017: routed calls go through the IInstanceRouter seam; the
|
||||
// production implementation delegates to CommunicationService.
|
||||
services.AddScoped<IInstanceRouter, CommunicationServiceInstanceRouter>();
|
||||
|
||||
Reference in New Issue
Block a user