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:
49
tests/ScadaLink.Commons.Tests/Entities/ApiKeyTests.cs
Normal file
49
tests/ScadaLink.Commons.Tests/Entities/ApiKeyTests.cs
Normal 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!));
|
||||
}
|
||||
}
|
||||
84
tests/ScadaLink.Commons.Tests/Types/ApiKeyHasherTests.cs
Normal file
84
tests/ScadaLink.Commons.Tests/Types/ApiKeyHasherTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
121
tests/ScadaLink.ManagementService.Tests/ApiKeyCreationTests.cs
Normal file
121
tests/ScadaLink.ManagementService.Tests/ApiKeyCreationTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user