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:
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