From 79039623e8f1944e043fced28ff87f773860f35e Mon Sep 17 00:00:00 2001 From: Fritiof Hedman Date: Sun, 7 Jun 2026 14:12:16 +0200 Subject: [PATCH] 1.7 ApiKey value type Co-Authored-By: Claude Opus 4.7 (1M context) --- IMPLEMENTATION.md | 2 +- src/FrameProcessor/Domain/ApiKey.cs | 33 ++++++++++ tests/FrameProcessor.Tests/ApiKeyTests.cs | 78 +++++++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 src/FrameProcessor/Domain/ApiKey.cs create mode 100644 tests/FrameProcessor.Tests/ApiKeyTests.cs diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index ad4eb46..3396476 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -63,7 +63,7 @@ Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor. - `enum { FloydSteinberg, Atkinson, Stucki, Jarvis }`. - JSON converter reading kebab-case (`floyd-steinberg`, etc.). -### [ ] 1.7 `ApiKey` +### [x] 1.7 `ApiKey` - `record struct` wrapping a string. - `bool Matches(string candidate)` using `CryptographicOperations.FixedTimeEquals` over UTF-8 bytes. diff --git a/src/FrameProcessor/Domain/ApiKey.cs b/src/FrameProcessor/Domain/ApiKey.cs new file mode 100644 index 0000000..4565f9b --- /dev/null +++ b/src/FrameProcessor/Domain/ApiKey.cs @@ -0,0 +1,33 @@ +using System.Security.Cryptography; +using System.Text; + +namespace FrameProcessor.Domain; + +/// +/// Wraps the shared API key. uses a constant-time comparison +/// over UTF-8 bytes to avoid leaking key length or content via timing side channels. +/// +public readonly record struct ApiKey +{ + private readonly string? _value; + + public ApiKey(string value) + { + ArgumentNullException.ThrowIfNull(value); + _value = value; + } + + public string Value => _value ?? string.Empty; + + public bool Matches(string? candidate) + { + if (candidate is null) + { + return false; + } + + var expectedBytes = Encoding.UTF8.GetBytes(Value); + var candidateBytes = Encoding.UTF8.GetBytes(candidate); + return CryptographicOperations.FixedTimeEquals(expectedBytes, candidateBytes); + } +} diff --git a/tests/FrameProcessor.Tests/ApiKeyTests.cs b/tests/FrameProcessor.Tests/ApiKeyTests.cs new file mode 100644 index 0000000..027aa51 --- /dev/null +++ b/tests/FrameProcessor.Tests/ApiKeyTests.cs @@ -0,0 +1,78 @@ +using FrameProcessor.Domain; + +namespace FrameProcessor.Tests; + +public class ApiKeyTests +{ + [Fact] + public void Matches_ReturnsTrue_ForIdenticalKey() + { + var key = new ApiKey("s3cret-value"); + Assert.True(key.Matches("s3cret-value")); + } + + [Fact] + public void Matches_ReturnsFalse_ForDifferentKey() + { + var key = new ApiKey("s3cret-value"); + Assert.False(key.Matches("wrong")); + } + + [Fact] + public void Matches_ReturnsFalse_ForKeyOfDifferentLength() + { + var key = new ApiKey("s3cret"); + Assert.False(key.Matches("s3cret-value")); + Assert.False(key.Matches("s3cre")); + } + + [Fact] + public void Matches_ReturnsFalse_ForNull() + { + var key = new ApiKey("s3cret"); + Assert.False(key.Matches(null)); + } + + [Fact] + public void Matches_ReturnsFalse_ForEmptyCandidate_WhenKeyIsNotEmpty() + { + var key = new ApiKey("s3cret"); + Assert.False(key.Matches(string.Empty)); + } + + [Fact] + public void Matches_IsCaseSensitive() + { + var key = new ApiKey("Secret"); + Assert.False(key.Matches("secret")); + Assert.False(key.Matches("SECRET")); + } + + [Fact] + public void Matches_HandlesMultiByteUtf8() + { + var key = new ApiKey("nyckel-äöå"); + Assert.True(key.Matches("nyckel-äöå")); + Assert.False(key.Matches("nyckel-aoa")); + } + + [Fact] + public void Constructor_ThrowsOnNull() + { + Assert.Throws(() => new ApiKey(null!)); + } + + [Fact] + public void Value_ReturnsConfiguredString() + { + var key = new ApiKey("abc"); + Assert.Equal("abc", key.Value); + } + + [Fact] + public void DefaultStruct_Value_IsEmpty() + { + var key = default(ApiKey); + Assert.Equal(string.Empty, key.Value); + } +}