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:
Joseph Doherty
2026-06-02 05:39:59 -04:00
parent b13d7b3d28
commit afa55981d5
32 changed files with 2117 additions and 1193 deletions
@@ -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>();