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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,77 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <summary>
/// ConfigurationDatabase-012: replaces the plaintext <c>KeyValue</c> column with a
/// <c>KeyHash</c> column holding a deterministic HMAC-SHA256 hash of the key.
/// </summary>
/// <remarks>
/// A hash is one-way: existing plaintext keys cannot be converted to hashes
/// without the originals. This migration therefore deletes all existing API-key
/// rows. <strong>Every existing API key must be re-issued</strong> after this
/// migration is applied — create new keys via the CLI / Management API / Central
/// UI, distribute the one-time plaintext to callers, and approve them on methods.
/// </remarks>
public partial class HashApiKeyValue : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Existing keys hold only plaintext, which cannot be hashed back. They
// must be re-issued, so remove them before the column change to keep the
// new unique KeyHash index satisfiable.
migrationBuilder.Sql("DELETE FROM ApiKeys;");
migrationBuilder.DropIndex(
name: "IX_ApiKeys_KeyValue",
table: "ApiKeys");
migrationBuilder.DropColumn(
name: "KeyValue",
table: "ApiKeys");
migrationBuilder.AddColumn<string>(
name: "KeyHash",
table: "ApiKeys",
type: "nvarchar(256)",
maxLength: 256,
nullable: false,
defaultValue: "");
migrationBuilder.CreateIndex(
name: "IX_ApiKeys_KeyHash",
table: "ApiKeys",
column: "KeyHash",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_ApiKeys_KeyHash",
table: "ApiKeys");
migrationBuilder.DropColumn(
name: "KeyHash",
table: "ApiKeys");
migrationBuilder.AddColumn<string>(
name: "KeyValue",
table: "ApiKeys",
type: "nvarchar(500)",
maxLength: 500,
nullable: false,
defaultValue: "");
migrationBuilder.CreateIndex(
name: "IX_ApiKeys_KeyValue",
table: "ApiKeys",
column: "KeyValue",
unique: true);
}
}
}

View File

@@ -348,10 +348,10 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.Property<bool>("IsEnabled")
.HasColumnType("bit");
b.Property<string>("KeyValue")
b.Property<string>("KeyHash")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Name")
.IsRequired()
@@ -360,7 +360,7 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.HasKey("Id");
b.HasIndex("KeyValue")
b.HasIndex("KeyHash")
.IsUnique();
b.HasIndex("Name")