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,95 +0,0 @@
using System.Security.Cryptography;
using System.Text;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
/// <summary>
/// Computes a deterministic, keyed hash of an inbound-API key value
/// (ConfigurationDatabase-012). API keys are persisted as this hash, never as
/// plaintext, so a configuration-database dump does not yield usable credentials.
/// The hash is deterministic so authentication can still resolve a key by value.
/// </summary>
public interface IApiKeyHasher
{
/// <summary>
/// Returns the keyed hash of <paramref name="apiKey"/> as a Base64 string.
/// The same input always produces the same output (deterministic), which keeps
/// the by-value lookup working.
/// </summary>
/// <param name="apiKey">The raw API key to hash.</param>
/// <returns>A Base64-encoded HMAC-SHA256 hash of the key.</returns>
string Hash(string apiKey);
}
/// <summary>
/// HMAC-SHA256 implementation of <see cref="IApiKeyHasher"/>. The HMAC key is a
/// server-side <em>pepper</em> bound from configuration. A per-row random salt is
/// intentionally NOT used: an API key is already a high-entropy random token, and a
/// random salt would break the deterministic by-value lookup the authentication
/// path relies on. The pepper instead binds every hash to this deployment, so a
/// stolen database is useless without it.
/// </summary>
public sealed class ApiKeyHasher : IApiKeyHasher
{
/// <summary>
/// Minimum accepted pepper length. A pepper shorter than this is treated as a
/// deployment misconfiguration and rejected — see <see cref="ApiKeyHasher(string)"/>.
/// </summary>
public const int MinimumPepperLength = 16;
private readonly byte[] _pepper;
/// <summary>
/// An unpeppered hasher (HMAC-SHA256 keyed with a fixed, empty-equivalent value).
/// It is still a one-way hash, but carries no deployment-specific binding. It
/// exists for tests and non-production wiring; production must construct an
/// <see cref="ApiKeyHasher"/> with a real pepper.
/// </summary>
public static ApiKeyHasher Default { get; } = new ApiKeyHasher();
private ApiKeyHasher()
{
// Fixed, deployment-independent key for the unpeppered default.
_pepper = Encoding.UTF8.GetBytes("ZB.MOM.WW.ScadaBridge.InboundApi.DefaultApiKeyHasher");
}
/// <summary>
/// Creates a hasher keyed with the given server-side pepper.
/// </summary>
/// <param name="pepper">Server-side HMAC key; must be at least <see cref="MinimumPepperLength"/> characters.</param>
/// <exception cref="ArgumentException">
/// Thrown if <paramref name="pepper"/> is null, blank, or shorter than
/// <see cref="MinimumPepperLength"/> — a missing or weak pepper is a deployment
/// misconfiguration and must fail loudly rather than degrade silently.
/// </exception>
public ApiKeyHasher(string pepper)
{
if (string.IsNullOrWhiteSpace(pepper))
{
throw new ArgumentException(
"The API-key HMAC pepper must be configured. Set a strong, random value " +
"in configuration (ScadaBridge:InboundApi:ApiKeyPepper).",
nameof(pepper));
}
if (pepper.Trim().Length < MinimumPepperLength)
{
throw new ArgumentException(
$"The API-key HMAC pepper is too weak: it must be at least {MinimumPepperLength} " +
"characters. Use a strong, random value.",
nameof(pepper));
}
_pepper = Encoding.UTF8.GetBytes(pepper);
}
/// <inheritdoc />
public string Hash(string apiKey)
{
ArgumentNullException.ThrowIfNull(apiKey);
using var hmac = new HMACSHA256(_pepper);
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(apiKey));
return Convert.ToBase64String(hash);
}
}