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:
Joseph Doherty
2026-05-17 05:42:52 -04:00
parent f23513c30b
commit 7da303d7bb
18 changed files with 2113 additions and 62 deletions

View File

@@ -0,0 +1,84 @@
using ScadaLink.Commons.Types.InboundApi;
namespace ScadaLink.Commons.Tests.Types;
/// <summary>
/// ConfigurationDatabase-012: the inbound-API bearer credential is stored as a
/// deterministic keyed hash (HMAC-SHA256 with a server-side pepper) rather than
/// plaintext. These tests pin the hasher contract that the entity, the validator,
/// and the management create-path all depend on.
/// </summary>
public class ApiKeyHasherTests
{
[Fact]
public void Hash_IsDeterministic_SameInputSameOutput()
{
var hasher = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
var first = hasher.Hash("some-api-key-value");
var second = hasher.Hash("some-api-key-value");
Assert.Equal(first, second);
}
[Fact]
public void Hash_DoesNotEqualPlaintext()
{
var hasher = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
var hash = hasher.Hash("some-api-key-value");
Assert.NotEqual("some-api-key-value", hash);
Assert.DoesNotContain("some-api-key-value", hash);
}
[Fact]
public void Hash_DifferentInputs_ProduceDifferentHashes()
{
var hasher = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
Assert.NotEqual(hasher.Hash("key-one"), hasher.Hash("key-two"));
}
[Fact]
public void Hash_DifferentPeppers_ProduceDifferentHashes()
{
var a = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
var b = new ApiKeyHasher("a-different-but-equally-long-pepper-val");
// The pepper binds the hash to the server: a stolen DB dump is useless
// without the pepper because the same key hashes differently under it.
Assert.NotEqual(a.Hash("same-api-key"), b.Hash("same-api-key"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("too-short")]
public void Constructor_MissingOrWeakPepper_FailsFast(string? pepper)
{
// The pepper must be present and of meaningful length; a missing or weak
// pepper is a deployment misconfiguration and must fail loudly.
Assert.Throws<ArgumentException>(() => new ApiKeyHasher(pepper!));
}
[Fact]
public void Hash_NullInput_Throws()
{
var hasher = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
Assert.Throws<ArgumentNullException>(() => hasher.Hash(null!));
}
[Fact]
public void Default_IsUsableWithoutAPepper()
{
// The unpeppered default exists for tests and non-production wiring; it is
// still a one-way HMAC-SHA256, just without the server-binding pepper.
var hash = ApiKeyHasher.Default.Hash("some-api-key-value");
Assert.NotEqual("some-api-key-value", hash);
Assert.Equal(ApiKeyHasher.Default.Hash("some-api-key-value"), hash);
}
}