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,49 @@
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Types.InboundApi;
namespace ScadaLink.Commons.Tests.Entities;
/// <summary>
/// ConfigurationDatabase-012: the <see cref="ApiKey"/> entity must never carry the
/// plaintext bearer credential as a persisted field — only its deterministic hash.
/// </summary>
public class ApiKeyTests
{
[Fact]
public void ApiKey_HasNoPlaintextKeyValueProperty()
{
// The plaintext key is shown to the operator once at creation and is never
// persisted. The entity must therefore expose KeyHash, not KeyValue.
var properties = typeof(ApiKey).GetProperties().Select(p => p.Name).ToArray();
Assert.DoesNotContain("KeyValue", properties);
Assert.Contains("KeyHash", properties);
}
[Fact]
public void Constructor_FromPlaintext_StoresHashNotPlaintext()
{
var key = new ApiKey("MES-Production", "the-secret-key-value");
Assert.NotEqual("the-secret-key-value", key.KeyHash);
Assert.Equal(ApiKeyHasher.Default.Hash("the-secret-key-value"), key.KeyHash);
}
[Fact]
public void FromHash_StoresHashVerbatim()
{
var key = ApiKey.FromHash("RecipeManager-Dev", "precomputed-hash-value");
Assert.Equal("RecipeManager-Dev", key.Name);
Assert.Equal("precomputed-hash-value", key.KeyHash);
}
[Fact]
public void Constructor_NullArguments_Throw()
{
Assert.Throws<ArgumentNullException>(() => new ApiKey(null!, "value"));
Assert.Throws<ArgumentNullException>(() => new ApiKey("name", (string)null!));
Assert.Throws<ArgumentNullException>(() => ApiKey.FromHash(null!, "hash"));
Assert.Throws<ArgumentNullException>(() => ApiKey.FromHash("name", null!));
}
}

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);
}
}

View File

@@ -134,4 +134,30 @@ public class SplitQueryBehaviourTests : IDisposable
Assert.Equal(2, loaded!.Attributes.Count);
Assert.Single(loaded.Scripts);
}
// ConfigurationDatabase-012: the ApiKey table must persist the bearer credential
// as a hash column (KeyHash) and must NOT carry a plaintext KeyValue column.
[Fact]
public void ApiKey_KeyHashColumn_IsMappedAndUniquelyIndexed()
{
var entityType = _context.Model.FindEntityType(typeof(ScadaLink.Commons.Entities.InboundApi.ApiKey))!;
var keyHash = entityType.FindProperty("KeyHash");
Assert.NotNull(keyHash);
Assert.False(keyHash!.IsNullable);
var hashIndex = entityType.GetIndexes()
.FirstOrDefault(i => i.Properties.Any(p => p.Name == "KeyHash"));
Assert.NotNull(hashIndex);
Assert.True(hashIndex!.IsUnique);
}
[Fact]
public void ApiKey_HasNoPlaintextKeyValueColumn()
{
var entityType = _context.Model.FindEntityType(typeof(ScadaLink.Commons.Entities.InboundApi.ApiKey))!;
Assert.Null(entityType.FindProperty("KeyValue"));
}
}

View File

@@ -0,0 +1,96 @@
using NSubstitute;
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Types.InboundApi;
namespace ScadaLink.InboundAPI.Tests;
/// <summary>
/// ConfigurationDatabase-012: <see cref="ApiKeyValidator"/> must authenticate by
/// hashing the presented candidate with the same HMAC-SHA256 pepper used at
/// creation, then comparing against the stored <see cref="ApiKey.KeyHash"/> — never
/// against a plaintext key. The comparison stays constant-time.
/// </summary>
public class ApiKeyHashValidationTests
{
private const string Pepper = "a-sufficiently-long-server-side-pepper-value";
private readonly IInboundApiRepository _repository = Substitute.For<IInboundApiRepository>();
private static ApiKey StoredKey(ApiKeyHasher hasher, string liveKey, int id = 1, bool enabled = true)
{
var key = ApiKey.FromHash("MES-Production", hasher.Hash(liveKey));
key.Id = id;
key.IsEnabled = enabled;
return key;
}
[Fact]
public async Task ValidateAsync_WithPepperedHasher_AcceptsKeyHashedWithSamePepper()
{
var hasher = new ApiKeyHasher(Pepper);
var stored = StoredKey(hasher, "live-secret-key");
var method = new ApiMethod("ingest", "return 1;") { Id = 10 };
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { stored });
_repository.GetMethodByNameAsync("ingest").Returns(method);
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey> { stored });
var validator = new ApiKeyValidator(_repository, hasher);
var result = await validator.ValidateAsync("live-secret-key", "ingest");
Assert.True(result.IsValid);
Assert.Equal(200, result.StatusCode);
}
[Fact]
public async Task ValidateAsync_WrongKey_FailsEvenWhenItHashesToSomethingNonNull()
{
var hasher = new ApiKeyHasher(Pepper);
var stored = StoredKey(hasher, "the-real-key");
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { stored });
var validator = new ApiKeyValidator(_repository, hasher);
var result = await validator.ValidateAsync("a-wrong-key", "ingest");
Assert.False(result.IsValid);
Assert.Equal(401, result.StatusCode);
}
[Fact]
public async Task ValidateAsync_StoredHashIsNotThePlaintextKey()
{
// Sanity guard: the value the validator compares against must be a hash, not
// the live secret — a DB dump must not yield a usable credential.
var hasher = new ApiKeyHasher(Pepper);
var stored = StoredKey(hasher, "live-secret-key");
Assert.NotEqual("live-secret-key", stored.KeyHash);
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { stored });
var validator = new ApiKeyValidator(_repository, hasher);
// Presenting the stored hash itself must NOT authenticate — only the live key does.
var result = await validator.ValidateAsync(stored.KeyHash, "ingest");
Assert.False(result.IsValid);
}
[Fact]
public async Task ValidateAsync_KeyHashedUnderADifferentPepper_DoesNotAuthenticate()
{
var creationHasher = new ApiKeyHasher(Pepper);
var stored = StoredKey(creationHasher, "live-secret-key");
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { stored });
// A validator running with a different pepper cannot recognise the key.
var otherHasher = new ApiKeyHasher("a-totally-different-server-side-pepper-val");
var validator = new ApiKeyValidator(_repository, otherHasher);
var result = await validator.ValidateAsync("live-secret-key", "ingest");
Assert.False(result.IsValid);
}
}

View File

@@ -0,0 +1,121 @@
using System.Text.Json;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.Management;
using ScadaLink.Commons.Types.InboundApi;
using ScadaLink.ManagementService;
namespace ScadaLink.ManagementService.Tests;
/// <summary>
/// ConfigurationDatabase-012: creating an API key must generate a random key,
/// persist only its peppered hash, and return the plaintext to the caller exactly
/// once. The plaintext must never reach the stored entity.
/// </summary>
public class ApiKeyCreationTests : TestKit, IDisposable
{
private const string Pepper = "a-sufficiently-long-server-side-pepper-value";
private readonly ServiceCollection _services = new();
private readonly IInboundApiRepository _apiRepo = Substitute.For<IInboundApiRepository>();
private readonly IAuditService _auditService = Substitute.For<IAuditService>();
public ApiKeyCreationTests()
{
_services.AddScoped(_ => _apiRepo);
_services.AddScoped(_ => _auditService);
_services.AddSingleton<IApiKeyHasher>(new ApiKeyHasher(Pepper));
}
private IActorRef CreateActor()
{
var sp = _services.BuildServiceProvider();
return Sys.ActorOf(Props.Create(() => new ManagementActor(sp, NullLogger<ManagementActor>.Instance)));
}
private static ManagementEnvelope Envelope(object command, params string[] roles) =>
new(new AuthenticatedUser("admin", "Admin User", roles, Array.Empty<string>()),
command, Guid.NewGuid().ToString("N"));
void IDisposable.Dispose() => Shutdown();
[Fact]
public void CreateApiKey_PersistsOnlyHash_NeverPlaintext()
{
ApiKey? persisted = null;
_apiRepo.AddApiKeyAsync(Arg.Do<ApiKey>(k => persisted = k))
.Returns(Task.CompletedTask);
var actor = CreateActor();
actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production"), "Admin"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
// The response must carry the one-time plaintext key shown to the operator.
var plaintext = ExtractPlaintextKey(response.JsonData);
Assert.False(string.IsNullOrWhiteSpace(plaintext));
// The stored entity must carry a hash, never the plaintext.
Assert.NotNull(persisted);
Assert.NotEqual(plaintext, persisted!.KeyHash);
// The persisted hash must equal the peppered hash of the returned plaintext.
var hasher = new ApiKeyHasher(Pepper);
Assert.Equal(hasher.Hash(plaintext!), persisted.KeyHash);
}
[Fact]
public void CreateApiKey_ResponseDoesNotEchoTheHash()
{
ApiKey? persisted = null;
_apiRepo.AddApiKeyAsync(Arg.Do<ApiKey>(k => persisted = k))
.Returns(Task.CompletedTask);
var actor = CreateActor();
actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production"), "Admin"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(persisted);
// The serialized response must not leak the stored hash as a usable artifact.
Assert.DoesNotContain(persisted!.KeyHash, response.JsonData);
}
[Fact]
public void CreateApiKey_TwoKeys_GenerateDistinctRandomValues()
{
var hashes = new List<string>();
_apiRepo.AddApiKeyAsync(Arg.Do<ApiKey>(k => hashes.Add(k.KeyHash)))
.Returns(Task.CompletedTask);
var actor = CreateActor();
actor.Tell(Envelope(new CreateApiKeyCommand("KeyA"), "Admin"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
actor.Tell(Envelope(new CreateApiKeyCommand("KeyB"), "Admin"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(2, hashes.Count);
Assert.NotEqual(hashes[0], hashes[1]);
}
/// <summary>
/// The create response is JSON carrying the one-time plaintext key under a
/// <c>PlaintextKey</c> (or <c>Key</c>) property.
/// </summary>
private static string? ExtractPlaintextKey(string json)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.TryGetProperty("plaintextKey", out var p) || root.TryGetProperty("PlaintextKey", out p))
return p.GetString();
if (root.TryGetProperty("key", out var k) || root.TryGetProperty("Key", out k))
return k.GetString();
return null;
}
}