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

@@ -46,7 +46,7 @@
<tr>
<th>ID</th>
<th>Name</th>
<th>Key Value</th>
<th>Key Hash</th>
<th style="width: 160px;">Actions</th>
</tr>
</thead>
@@ -62,7 +62,7 @@
<span class="badge bg-secondary ms-1">Disabled</span>
}
</td>
<td><code>@MaskKeyValue(key.KeyValue)</code></td>
<td><code>@MaskKeyValue(key.KeyHash)</code></td>
<td>
<div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm py-0 px-2"

View File

@@ -1,15 +1,61 @@
using ScadaLink.Commons.Types.InboundApi;
namespace ScadaLink.Commons.Entities.InboundApi;
/// <summary>
/// An inbound-API bearer credential. Per ConfigurationDatabase-012 the plaintext key
/// is never persisted: the entity stores only <see cref="KeyHash"/>, a deterministic
/// keyed hash of the key (HMAC-SHA256 with a server-side pepper). The plaintext is
/// generated at creation, shown to the operator exactly once, and then discarded.
/// </summary>
public class ApiKey
{
public int Id { get; set; }
public string Name { get; set; }
public string KeyValue { get; set; }
/// <summary>
/// Deterministic keyed hash of the API key value. This is the only form of the
/// credential persisted; the plaintext key is never stored. Authentication hashes
/// the presented candidate with the same scheme and compares against this value.
/// </summary>
public string KeyHash { get; set; }
public bool IsEnabled { get; set; }
/// <summary>
/// Creates an API key from a plaintext value, immediately hashing it with the
/// unpeppered default hasher (<see cref="ApiKeyHasher.Default"/>) so the entity
/// never holds the plaintext. Production code paths that have a configured pepper
/// should use <see cref="FromHash(string, string)"/> with a peppered hash instead.
/// </summary>
public ApiKey(string name, string keyValue)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
KeyValue = keyValue ?? throw new ArgumentNullException(nameof(keyValue));
if (keyValue is null) throw new ArgumentNullException(nameof(keyValue));
KeyHash = ApiKeyHasher.Default.Hash(keyValue);
}
/// <summary>
/// Parameterless constructor for the EF Core materializer. Application code uses
/// <see cref="ApiKey(string, string)"/> or <see cref="FromHash(string, string)"/>.
/// </summary>
private ApiKey()
{
Name = string.Empty;
KeyHash = string.Empty;
}
/// <summary>
/// Creates an API key from an already-computed key hash. Used by the creation
/// path, which generates a random key, hashes it with the configured (peppered)
/// <see cref="IApiKeyHasher"/>, and stores only the resulting hash.
/// </summary>
public static ApiKey FromHash(string name, string keyHash)
{
return new ApiKey
{
Name = name ?? throw new ArgumentNullException(nameof(name)),
KeyHash = keyHash ?? throw new ArgumentNullException(nameof(keyHash)),
};
}
}

View File

@@ -0,0 +1,92 @@
using System.Security.Cryptography;
using System.Text;
namespace ScadaLink.Commons.Types.InboundApi;
/// <summary>
/// Computes a deterministic, keyed hash of an inbound-API key value
/// (ConfigurationDatabase-012). API keys are persisted as this hash, never as
/// plaintext, so a configuration-database dump does not yield usable credentials.
/// The hash is deterministic so authentication can still resolve a key by value.
/// </summary>
public interface IApiKeyHasher
{
/// <summary>
/// Returns the keyed hash of <paramref name="apiKey"/> as a Base64 string.
/// The same input always produces the same output (deterministic), which keeps
/// the by-value lookup working.
/// </summary>
string Hash(string apiKey);
}
/// <summary>
/// HMAC-SHA256 implementation of <see cref="IApiKeyHasher"/>. The HMAC key is a
/// server-side <em>pepper</em> bound from configuration. A per-row random salt is
/// intentionally NOT used: an API key is already a high-entropy random token, and a
/// random salt would break the deterministic by-value lookup the authentication
/// path relies on. The pepper instead binds every hash to this deployment, so a
/// stolen database is useless without it.
/// </summary>
public sealed class ApiKeyHasher : IApiKeyHasher
{
/// <summary>
/// Minimum accepted pepper length. A pepper shorter than this is treated as a
/// deployment misconfiguration and rejected — see <see cref="ApiKeyHasher(string)"/>.
/// </summary>
public const int MinimumPepperLength = 16;
private readonly byte[] _pepper;
/// <summary>
/// An unpeppered hasher (HMAC-SHA256 keyed with a fixed, empty-equivalent value).
/// It is still a one-way hash, but carries no deployment-specific binding. It
/// exists for tests and non-production wiring; production must construct an
/// <see cref="ApiKeyHasher"/> with a real pepper.
/// </summary>
public static ApiKeyHasher Default { get; } = new ApiKeyHasher();
private ApiKeyHasher()
{
// Fixed, deployment-independent key for the unpeppered default.
_pepper = Encoding.UTF8.GetBytes("ScadaLink.InboundApi.DefaultApiKeyHasher");
}
/// <summary>
/// Creates a hasher keyed with the given server-side pepper.
/// </summary>
/// <exception cref="ArgumentException">
/// Thrown if <paramref name="pepper"/> is null, blank, or shorter than
/// <see cref="MinimumPepperLength"/> — a missing or weak pepper is a deployment
/// misconfiguration and must fail loudly rather than degrade silently.
/// </exception>
public ApiKeyHasher(string pepper)
{
if (string.IsNullOrWhiteSpace(pepper))
{
throw new ArgumentException(
"The API-key HMAC pepper must be configured. Set a strong, random value " +
"in configuration (ScadaLink:InboundApi:ApiKeyPepper).",
nameof(pepper));
}
if (pepper.Trim().Length < MinimumPepperLength)
{
throw new ArgumentException(
$"The API-key HMAC pepper is too weak: it must be at least {MinimumPepperLength} " +
"characters. Use a strong, random value.",
nameof(pepper));
}
_pepper = Encoding.UTF8.GetBytes(pepper);
}
/// <inheritdoc />
public string Hash(string apiKey)
{
ArgumentNullException.ThrowIfNull(apiKey);
using var hmac = new HMACSHA256(_pepper);
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(apiKey));
return Convert.ToBase64String(hash);
}
}

View File

@@ -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();
}
}

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")

View File

@@ -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);

View File

@@ -2,6 +2,7 @@ using System.Security.Cryptography;
using System.Text;
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Types.InboundApi;
namespace ScadaLink.InboundAPI;
@@ -12,14 +13,24 @@ namespace ScadaLink.InboundAPI;
public class ApiKeyValidator
{
private readonly IInboundApiRepository _repository;
private readonly IApiKeyHasher _hasher;
// InboundAPI-011: the single message used for both "method not found" and
// "key not approved" so the two outcomes are indistinguishable to the caller.
private const string NotApprovedMessage = "API key not approved for this method";
public ApiKeyValidator(IInboundApiRepository repository)
/// <param name="repository">Inbound-API data access.</param>
/// <param name="hasher">
/// ConfigurationDatabase-012: hashes the presented candidate key with the same
/// HMAC-SHA256 pepper used at key creation, so authentication compares hashes —
/// the database never holds a plaintext credential. Defaults to the unpeppered
/// <see cref="ApiKeyHasher.Default"/>; production wiring injects the configured,
/// peppered hasher.
/// </param>
public ApiKeyValidator(IInboundApiRepository repository, IApiKeyHasher? hasher = null)
{
_repository = repository;
_hasher = hasher ?? ApiKeyHasher.Default;
}
/// <summary>
@@ -37,13 +48,17 @@ public class ApiKeyValidator
}
// InboundAPI-003: do NOT resolve the key with a secret-equality lookup
// (GetApiKeyByValueAsync translates to a SQL "WHERE KeyValue = @secret" early-exit
// comparison — a timing side-channel). Fetch all keys and match the secret
// in-process with a constant-time comparison so neither match position nor
// secret length is observable to a network attacker.
// (GetApiKeyByValueAsync translates to a SQL "WHERE KeyHash = @hash" early-exit
// comparison — a timing side-channel). Fetch all keys and match in-process
// with a constant-time comparison so neither match position nor length is
// observable to a network attacker.
// ConfigurationDatabase-012: the database stores only the HMAC hash of each
// key, so the presented candidate is hashed with the same pepper and the
// comparison runs over the resulting hashes — never over plaintext.
var candidateHash = _hasher.Hash(apiKeyValue);
var apiKey = FindKeyConstantTime(
await _repository.GetAllApiKeysAsync(cancellationToken),
apiKeyValue);
candidateHash);
if (apiKey == null || !apiKey.IsEnabled)
{
return ApiKeyValidationResult.Unauthorized("Invalid or disabled API key");
@@ -73,19 +88,21 @@ public class ApiKeyValidator
}
/// <summary>
/// InboundAPI-003: Finds the key whose value matches <paramref name="candidate"/>
/// using <see cref="CryptographicOperations.FixedTimeEquals"/> over the UTF-8 bytes.
/// InboundAPI-003 / ConfigurationDatabase-012: Finds the key whose stored
/// <see cref="ApiKey.KeyHash"/> matches <paramref name="candidateHash"/> — the
/// HMAC hash of the presented key — using
/// <see cref="CryptographicOperations.FixedTimeEquals"/> over the UTF-8 bytes.
/// Every candidate row is compared so that the running time does not depend on the
/// match position; length mismatches return false without leaking length timing.
/// </summary>
private static ApiKey? FindKeyConstantTime(IEnumerable<ApiKey> keys, string candidate)
private static ApiKey? FindKeyConstantTime(IEnumerable<ApiKey> keys, string candidateHash)
{
var candidateBytes = Encoding.UTF8.GetBytes(candidate);
var candidateBytes = Encoding.UTF8.GetBytes(candidateHash);
ApiKey? match = null;
foreach (var key in keys)
{
var keyBytes = Encoding.UTF8.GetBytes(key.KeyValue);
var keyBytes = Encoding.UTF8.GetBytes(key.KeyHash);
if (CryptographicOperations.FixedTimeEquals(candidateBytes, keyBytes))
{
// Do not break — continuing keeps the loop's timing independent of

View File

@@ -17,4 +17,18 @@ public class InboundApiOptions
/// bounds per-request allocations.
/// </summary>
public long MaxRequestBodyBytes { get; set; } = DefaultMaxRequestBodyBytes;
/// <summary>
/// ConfigurationDatabase-012: server-side HMAC pepper used to hash inbound-API
/// bearer credentials. API keys are persisted as a deterministic keyed hash, never
/// as plaintext; this pepper is the HMAC key that binds every hash to this
/// deployment, so a stolen configuration database is not directly exploitable.
/// <para>
/// This is a secret: supply a strong, random value via configuration or a secret
/// store, never hard-coded. It must be present and at least
/// <see cref="ScadaLink.Commons.Types.InboundApi.ApiKeyHasher.MinimumPepperLength"/>
/// characters — <c>AddInboundAPI</c> fails fast otherwise.
/// </para>
/// </summary>
public string ApiKeyPepper { get; set; } = string.Empty;
}

View File

@@ -1,4 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Types.InboundApi;
namespace ScadaLink.InboundAPI;
@@ -10,6 +12,18 @@ public static class ServiceCollectionExtensions
services.AddSingleton<InboundScriptExecutor>();
services.AddScoped<RouteHelper>();
// ConfigurationDatabase-012: API keys are persisted as a deterministic HMAC
// hash, never as plaintext. The hasher is keyed with a server-side pepper
// bound from configuration (InboundApiOptions.ApiKeyPepper). Constructing
// ApiKeyHasher throws if the pepper is missing or weak — so a misconfigured
// deployment fails fast the first time the hasher is resolved rather than
// silently hashing with no pepper.
services.AddSingleton<IApiKeyHasher>(sp =>
{
var options = sp.GetRequiredService<IOptions<InboundApiOptions>>().Value;
return new ApiKeyHasher(options.ApiKeyPepper);
});
// InboundAPI-017: routed calls go through the IInstanceRouter seam; the
// production implementation delegates to CommunicationService.
services.AddScoped<IInstanceRouter, CommunicationServiceInstanceRouter>();

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)