feat(auth)!: ScadaBridge retire SQL Server ApiKey entity + ApprovedApiKeyIds + legacy hashing; EF migration RetireInboundApiKeyStore; re-issue runbook + CHANGELOG (re-arch C5/E) — BREAKING: X-API-Key -> Bearer sbk_, keys re-issued
This commit is contained in:
@@ -1,168 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.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;
|
||||
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";
|
||||
|
||||
/// <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>
|
||||
/// Validates an API key for a given method.
|
||||
/// Returns (isValid, apiKey, statusCode, errorMessage).
|
||||
/// </summary>
|
||||
/// <param name="apiKeyValue">The API key value from the X-API-Key header.</param>
|
||||
/// <param name="methodName">The name of the method being invoked.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
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 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),
|
||||
candidateHash);
|
||||
if (apiKey == null || !apiKey.IsEnabled)
|
||||
{
|
||||
return ApiKeyValidationResult.Unauthorized("Invalid or disabled API key");
|
||||
}
|
||||
|
||||
// InboundAPI-011: "method not found" and "key not approved" must produce an
|
||||
// indistinguishable response. Otherwise a caller holding any valid key could
|
||||
// enumerate which method names exist by observing the status/message
|
||||
// difference. Both cases return 403 with the identical message below, and the
|
||||
// caller-supplied method name is never echoed back into the response.
|
||||
var method = await _repository.GetMethodByNameAsync(methodName, cancellationToken);
|
||||
if (method == null)
|
||||
{
|
||||
return ApiKeyValidationResult.Forbidden(NotApprovedMessage);
|
||||
}
|
||||
|
||||
// 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(NotApprovedMessage);
|
||||
}
|
||||
|
||||
return ApiKeyValidationResult.Valid(apiKey, method);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 candidateHash)
|
||||
{
|
||||
var candidateBytes = Encoding.UTF8.GetBytes(candidateHash);
|
||||
ApiKey? match = null;
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
var keyBytes = Encoding.UTF8.GetBytes(key.KeyHash);
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the API key validation was successful.
|
||||
/// </summary>
|
||||
public bool IsValid { get; private init; }
|
||||
/// <summary>
|
||||
/// The HTTP status code for the validation result.
|
||||
/// </summary>
|
||||
public int StatusCode { get; private init; }
|
||||
/// <summary>
|
||||
/// Error message if validation failed, if any.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; private init; }
|
||||
/// <summary>
|
||||
/// The validated API key, if successful.
|
||||
/// </summary>
|
||||
public ApiKey? ApiKey { get; private init; }
|
||||
/// <summary>
|
||||
/// The validated API method, if successful.
|
||||
/// </summary>
|
||||
public ApiMethod? Method { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful validation result.
|
||||
/// </summary>
|
||||
/// <param name="apiKey">The validated API key.</param>
|
||||
/// <param name="method">The validated API method.</param>
|
||||
public static ApiKeyValidationResult Valid(ApiKey apiKey, ApiMethod method) =>
|
||||
new() { IsValid = true, StatusCode = 200, ApiKey = apiKey, Method = method };
|
||||
|
||||
/// <summary>
|
||||
/// Creates an unauthorized validation result.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message.</param>
|
||||
public static ApiKeyValidationResult Unauthorized(string message) =>
|
||||
new() { IsValid = false, StatusCode = 401, ErrorMessage = message };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a forbidden validation result.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message.</param>
|
||||
public static ApiKeyValidationResult Forbidden(string message) =>
|
||||
new() { IsValid = false, StatusCode = 403, ErrorMessage = message };
|
||||
}
|
||||
@@ -20,15 +20,17 @@ public class InboundApiOptions
|
||||
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.
|
||||
/// Server-side HMAC pepper for inbound-API bearer credentials, bound from
|
||||
/// <c>ScadaBridge:InboundApi:ApiKeyPepper</c>.
|
||||
/// <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="ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi.ApiKeyHasher.MinimumPepperLength"/>
|
||||
/// characters — <c>AddInboundAPI</c> fails fast otherwise.
|
||||
/// Auth re-arch (C5): the legacy SQL Server hashing path that consumed this
|
||||
/// property was retired. The pepper itself is still required — the shared
|
||||
/// ZB.MOM.WW.Auth.ApiKeys verifier reads the SAME configuration key
|
||||
/// (<c>PepperSecretName</c> in the Host composition root points at it) to pepper
|
||||
/// the SQLite-stored keys. It is a secret: supply a strong, random value
|
||||
/// (≥ 16 characters), DIFFERENT per environment, via a secret store and never
|
||||
/// hard-coded. This property is retained so the section still binds cleanly; the
|
||||
/// value is consumed by the library verifier, not by <c>AddInboundAPI</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public string ApiKeyPepper { get; set; } = string.Empty;
|
||||
|
||||
@@ -1,33 +1,23 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers all inbound API services (API key validator, script executor, route helper, and endpoint filter).
|
||||
/// Registers all inbound API services (script executor, route helper, and endpoint filter).
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to register into.</param>
|
||||
public static IServiceCollection AddInboundAPI(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<ApiKeyValidator>();
|
||||
// Auth re-arch (C5): inbound authentication is handled by the shared
|
||||
// ZB.MOM.WW.Auth.ApiKeys verifier (Bearer sbk_<keyId>_<secret>), registered by
|
||||
// AddZbApiKeyAuth in the Host composition root. The legacy ApiKeyValidator +
|
||||
// peppered IApiKeyHasher and the SQL Server ApiKey store were retired, so this
|
||||
// extension no longer registers them.
|
||||
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