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

@@ -18,6 +18,7 @@ using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.DebugView;
using ScadaLink.Commons.Messages.Management;
using ScadaLink.Commons.Messages.RemoteQuery;
using ScadaLink.Commons.Types.InboundApi;
using ScadaLink.DeploymentManager;
using ScadaLink.HealthMonitoring;
using ScadaLink.Communication;
@@ -1174,18 +1175,42 @@ public class ManagementActor : ReceiveActor
private static async Task<object?> HandleListApiKeys(IServiceProvider sp)
{
var repo = sp.GetRequiredService<IInboundApiRepository>();
return await repo.GetAllApiKeysAsync();
var keys = await repo.GetAllApiKeysAsync();
// ConfigurationDatabase-012: list/read paths must not expose the stored key
// hash — it is a credential artifact. Only identity and status are returned;
// the plaintext key is shown once at creation and is never retrievable.
return keys
.Select(k => new { k.Id, k.Name, k.IsEnabled })
.ToList();
}
private static async Task<object?> HandleCreateApiKey(IServiceProvider sp, CreateApiKeyCommand cmd, string user)
{
var repo = sp.GetRequiredService<IInboundApiRepository>();
var keyValue = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
var apiKey = new ApiKey(cmd.Name, keyValue) { IsEnabled = true };
// ConfigurationDatabase-012: generate a high-entropy random key, persist only
// its peppered hash, and return the plaintext to the caller exactly once. The
// plaintext is never stored — the ApiKey entity carries only KeyHash.
var hasher = sp.GetService<IApiKeyHasher>() ?? ApiKeyHasher.Default;
var plaintextKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
var apiKey = ApiKey.FromHash(cmd.Name, hasher.Hash(plaintextKey));
apiKey.IsEnabled = true;
await repo.AddApiKeyAsync(apiKey);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Create", "ApiKey", apiKey.Id.ToString(), apiKey.Name, new { apiKey.Id, apiKey.Name, apiKey.IsEnabled });
return apiKey;
await AuditAsync(sp, user, "Create", "ApiKey", apiKey.Id.ToString(), apiKey.Name,
new { apiKey.Id, apiKey.Name, apiKey.IsEnabled });
// The plaintext key is shown to the operator only here, in the create response;
// it cannot be retrieved later. The stored hash is deliberately not returned.
return new
{
apiKey.Id,
apiKey.Name,
apiKey.IsEnabled,
PlaintextKey = plaintextKey,
};
}
private static async Task<object?> HandleDeleteApiKey(IServiceProvider sp, DeleteApiKeyCommand cmd, string user)