feat(auth)!: ScadaBridge retire SQL Server ApiKey entity + ApprovedApiKeyIds + legacy hashing; EF migration RetireInboundApiKeyStore; re-issue runbook + CHANGELOG (re-arch C5/E) — BREAKING: X-API-Key -> Bearer sbk_, keys re-issued
This commit is contained in:
@@ -1,96 +0,0 @@
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// ConfigurationDatabase-012: <see cref="ApiKeyValidator"/> must authenticate by
|
||||
/// hashing the presented candidate with the same HMAC-SHA256 pepper used at
|
||||
/// creation, then comparing against the stored <see cref="ApiKey.KeyHash"/> — never
|
||||
/// against a plaintext key. The comparison stays constant-time.
|
||||
/// </summary>
|
||||
public class ApiKeyHashValidationTests
|
||||
{
|
||||
private const string Pepper = "a-sufficiently-long-server-side-pepper-value";
|
||||
|
||||
private readonly IInboundApiRepository _repository = Substitute.For<IInboundApiRepository>();
|
||||
|
||||
private static ApiKey StoredKey(ApiKeyHasher hasher, string liveKey, int id = 1, bool enabled = true)
|
||||
{
|
||||
var key = ApiKey.FromHash("MES-Production", hasher.Hash(liveKey));
|
||||
key.Id = id;
|
||||
key.IsEnabled = enabled;
|
||||
return key;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WithPepperedHasher_AcceptsKeyHashedWithSamePepper()
|
||||
{
|
||||
var hasher = new ApiKeyHasher(Pepper);
|
||||
var stored = StoredKey(hasher, "live-secret-key");
|
||||
var method = new ApiMethod("ingest", "return 1;") { Id = 10 };
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { stored });
|
||||
_repository.GetMethodByNameAsync("ingest").Returns(method);
|
||||
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey> { stored });
|
||||
|
||||
var validator = new ApiKeyValidator(_repository, hasher);
|
||||
|
||||
var result = await validator.ValidateAsync("live-secret-key", "ingest");
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(200, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WrongKey_FailsEvenWhenItHashesToSomethingNonNull()
|
||||
{
|
||||
var hasher = new ApiKeyHasher(Pepper);
|
||||
var stored = StoredKey(hasher, "the-real-key");
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { stored });
|
||||
|
||||
var validator = new ApiKeyValidator(_repository, hasher);
|
||||
|
||||
var result = await validator.ValidateAsync("a-wrong-key", "ingest");
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_StoredHashIsNotThePlaintextKey()
|
||||
{
|
||||
// Sanity guard: the value the validator compares against must be a hash, not
|
||||
// the live secret — a DB dump must not yield a usable credential.
|
||||
var hasher = new ApiKeyHasher(Pepper);
|
||||
var stored = StoredKey(hasher, "live-secret-key");
|
||||
|
||||
Assert.NotEqual("live-secret-key", stored.KeyHash);
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { stored });
|
||||
var validator = new ApiKeyValidator(_repository, hasher);
|
||||
|
||||
// Presenting the stored hash itself must NOT authenticate — only the live key does.
|
||||
var result = await validator.ValidateAsync(stored.KeyHash, "ingest");
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_KeyHashedUnderADifferentPepper_DoesNotAuthenticate()
|
||||
{
|
||||
var creationHasher = new ApiKeyHasher(Pepper);
|
||||
var stored = StoredKey(creationHasher, "live-secret-key");
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { stored });
|
||||
|
||||
// A validator running with a different pepper cannot recognise the key.
|
||||
var otherHasher = new ApiKeyHasher("a-totally-different-server-side-pepper-val");
|
||||
var validator = new ApiKeyValidator(_repository, otherHasher);
|
||||
|
||||
var result = await validator.ValidateAsync("live-secret-key", "ingest");
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-1: Tests for API key validation — X-API-Key header, enabled/disabled keys,
|
||||
/// method approval.
|
||||
/// </summary>
|
||||
public class ApiKeyValidatorTests
|
||||
{
|
||||
private readonly IInboundApiRepository _repository = Substitute.For<IInboundApiRepository>();
|
||||
private readonly ApiKeyValidator _validator;
|
||||
|
||||
public ApiKeyValidatorTests()
|
||||
{
|
||||
_validator = new ApiKeyValidator(_repository);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingApiKey_Returns401()
|
||||
{
|
||||
var result = await _validator.ValidateAsync(null, "testMethod");
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyApiKey_Returns401()
|
||||
{
|
||||
var result = await _validator.ValidateAsync("", "testMethod");
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidApiKey_Returns401()
|
||||
{
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey>());
|
||||
|
||||
var result = await _validator.ValidateAsync("bad-key", "testMethod");
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisabledApiKey_Returns401()
|
||||
{
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = false };
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
|
||||
var result = await _validator.ValidateAsync("valid-key", "testMethod");
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidKey_MethodNotFound_IsIndistinguishableFromNotApproved()
|
||||
{
|
||||
// InboundAPI-011: a "method not found" response must not be observably
|
||||
// different from a "key not approved" response, or a caller holding any
|
||||
// valid key could enumerate which method names exist on the central API.
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
var method = new ApiMethod("realMethod", "return 1;") { Id = 10 };
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
_repository.GetMethodByNameAsync("nonExistent").Returns((ApiMethod?)null);
|
||||
_repository.GetMethodByNameAsync("realMethod").Returns(method);
|
||||
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey>());
|
||||
|
||||
var notFound = await _validator.ValidateAsync("valid-key", "nonExistent");
|
||||
var notApproved = await _validator.ValidateAsync("valid-key", "realMethod");
|
||||
|
||||
Assert.False(notFound.IsValid);
|
||||
Assert.False(notApproved.IsValid);
|
||||
// Status code and error message must be identical so existence is not observable.
|
||||
Assert.Equal(notApproved.StatusCode, notFound.StatusCode);
|
||||
Assert.Equal(notApproved.ErrorMessage, notFound.ErrorMessage);
|
||||
Assert.Equal(403, notFound.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidKey_MethodNotFound_ErrorMessageDoesNotEchoMethodName()
|
||||
{
|
||||
// InboundAPI-011: the error body must not echo the caller-supplied method
|
||||
// name back verbatim (reflected-input) and must not confirm non-existence.
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
_repository.GetMethodByNameAsync("probe-XYZ").Returns((ApiMethod?)null);
|
||||
|
||||
var result = await _validator.ValidateAsync("valid-key", "probe-XYZ");
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.DoesNotContain("probe-XYZ", result.ErrorMessage ?? string.Empty);
|
||||
Assert.DoesNotContain("not found", result.ErrorMessage ?? string.Empty,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidKey_NotApprovedForMethod_Returns403()
|
||||
{
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
var method = new ApiMethod("testMethod", "return 1;") { Id = 10 };
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
_repository.GetMethodByNameAsync("testMethod").Returns(method);
|
||||
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey>());
|
||||
|
||||
var result = await _validator.ValidateAsync("valid-key", "testMethod");
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(403, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidKey_ApprovedForMethod_ReturnsValid()
|
||||
{
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
var method = new ApiMethod("testMethod", "return 1;") { Id = 10 };
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
_repository.GetMethodByNameAsync("testMethod").Returns(method);
|
||||
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey> { key });
|
||||
|
||||
var result = await _validator.ValidateAsync("valid-key", "testMethod");
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(200, result.StatusCode);
|
||||
Assert.Equal(key, result.ApiKey);
|
||||
Assert.Equal(method, result.Method);
|
||||
}
|
||||
|
||||
// --- InboundAPI-003: API key must not be matched with a non-constant-time
|
||||
// (timing-oracle) secret-equality lookup. ---
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_DoesNotUseSecretEqualityLookup()
|
||||
{
|
||||
// GetApiKeyByValueAsync translates to a SQL "WHERE KeyValue = @secret" early-exit
|
||||
// comparison — a timing side-channel. The validator must not call it.
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
var method = new ApiMethod("testMethod", "return 1;") { Id = 10 };
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
_repository.GetMethodByNameAsync("testMethod").Returns(method);
|
||||
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey> { key });
|
||||
|
||||
await _validator.ValidateAsync("valid-key", "testMethod");
|
||||
|
||||
await _repository.DidNotReceive()
|
||||
.GetApiKeyByValueAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WrongKey_ConstantTimePath_Returns401()
|
||||
{
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
|
||||
var result = await _validator.ValidateAsync("wrong-key", "testMethod");
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_KeyOfDifferentLength_Returns401()
|
||||
{
|
||||
// FixedTimeEquals over UTF-8 bytes must reject length mismatches without leaking.
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
|
||||
var result = await _validator.ValidateAsync("valid-key-with-extra", "testMethod");
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -262,7 +262,8 @@ public class AuditWriteMiddlewareTests
|
||||
var mw = CreateMiddleware(_ =>
|
||||
{
|
||||
// The endpoint handler is expected to stash the resolved API key
|
||||
// name here once ApiKeyValidator.ValidateAsync has succeeded.
|
||||
// name here once the shared ZB.MOM.WW.Auth.ApiKeys verifier has
|
||||
// authenticated the Bearer token.
|
||||
ctx.Items[AuditWriteMiddleware.AuditActorItemKey] = "integration-svc";
|
||||
ctx.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
|
||||
Reference in New Issue
Block a user