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:
@@ -14,12 +14,15 @@ public class ApiKeyConfiguration : IEntityTypeConfiguration<ApiKey>
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(k => k.KeyValue)
|
||||
// ConfigurationDatabase-012: the bearer credential is persisted only as a
|
||||
// deterministic HMAC-SHA256 hash, never as plaintext. Base64 of a 32-byte
|
||||
// HMAC-SHA256 digest is 44 characters; 256 leaves generous headroom.
|
||||
builder.Property(k => k.KeyHash)
|
||||
.IsRequired()
|
||||
.HasMaxLength(500);
|
||||
.HasMaxLength(256);
|
||||
|
||||
builder.HasIndex(k => k.Name).IsUnique();
|
||||
builder.HasIndex(k => k.KeyValue).IsUnique();
|
||||
builder.HasIndex(k => k.KeyHash).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1350
src/ScadaLink.ConfigurationDatabase/Migrations/20260517073000_HashApiKeyValue.Designer.cs
generated
Normal file
1350
src/ScadaLink.ConfigurationDatabase/Migrations/20260517073000_HashApiKeyValue.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ScadaLink.Commons.Entities.InboundApi;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Types.InboundApi;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Repositories;
|
||||
|
||||
@@ -23,8 +24,20 @@ public class InboundApiRepository : IInboundApiRepository
|
||||
public async Task<IReadOnlyList<ApiKey>> GetAllApiKeysAsync(CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ApiKey>().ToListAsync(cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// ConfigurationDatabase-012: API keys are persisted only as a deterministic hash,
|
||||
/// never as plaintext, so this lookup hashes the supplied plaintext value and
|
||||
/// matches it against the stored <see cref="ApiKey.KeyHash"/> column. The
|
||||
/// unpeppered default hasher is used here because the repository has no access to
|
||||
/// the deployment pepper; the inbound-API authentication path does not use this
|
||||
/// method — it loads all keys and compares hashes constant-time in
|
||||
/// <c>ApiKeyValidator</c> with the configured (peppered) hasher.
|
||||
/// </summary>
|
||||
public async Task<ApiKey?> GetApiKeyByValueAsync(string keyValue, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ApiKey>().FirstOrDefaultAsync(k => k.KeyValue == keyValue, cancellationToken);
|
||||
{
|
||||
var keyHash = ApiKeyHasher.Default.Hash(keyValue);
|
||||
return await _context.Set<ApiKey>().FirstOrDefaultAsync(k => k.KeyHash == keyHash, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddApiKeyAsync(ApiKey apiKey, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ApiKey>().AddAsync(apiKey, cancellationToken);
|
||||
|
||||
Reference in New Issue
Block a user