diff --git a/CLAUDE.md b/CLAUDE.md index c489eaa..dc2dfb9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,6 +36,9 @@ This is a .NET library (`ktsu.Essentials`) providing high-performance interfaces - `Essentials/IncrementalHashAdapter.cs` - Public adapter over `System.Security.Cryptography.IncrementalHash`, shared by the cryptographic hash providers - `Essentials/BufferingIncrementalHash.cs` - Internal buffering fallback behind the `CreateIncremental()` default body - `Shared/NonCryptoIncrementalHash.cs` - Adapter over `NonCryptographicHashAlgorithm`, linked into the six `System.IO.Hashing` providers rather than placed in the interfaces-only package +- `Essentials/IKeyedHashProvider.cs` - Keyed hashing (HMAC) interface for authenticating data with a secret key +- `Essentials/FixedTimeComparison.cs` - Static fixed-time byte comparison for tags obtained outside `IKeyedHashProvider.Verify` +- `Shared/HmacKeyedHashCore.cs` - HMAC implementation shared across algorithms, linked into the three keyed hash provider projects rather than placed in the interfaces package - `Essentials/ISerializationProvider.cs` - Object serialization/deserialization interface - `Essentials/ISerializationOptions.cs` - Configurable serialization options (naming, inclusion, boxing policies) - `Essentials/ICacheProvider.cs` - Generic cache interface with expiration and get-or-add @@ -58,6 +61,7 @@ Each provider implementation ships as its own project/package named `Essentials. - **ObfuscationProviders**: Xor, Caesar, Reverse, BitRotate, Base64, Hex, Composite - **EncryptionProviders**: Aes - **HashProviders**: MD5, SHA1, SHA256, SHA384, SHA512, FNV1_32, FNV1a_32, FNV1_64, FNV1a_64, CRC32, CRC64, XxHash32, XxHash64, XxHash3, XxHash128 +- **KeyedHashProviders**: HmacSha256, HmacSha384, HmacSha512 - **SerializationProviders**: Json (System.Text.Json), NewtonsoftJson, Yaml, Toml - **FileSystemProviders**: Native - **CommandExecutors**: Native @@ -84,7 +88,7 @@ All provider interfaces follow a consistent three-tier pattern: 1. **Core Try\* methods**: Buffer-based methods over `Span` or `Stream`. Span overloads are `bool TryX(source, destination, out int bytesWritten)`, paired with a `GetMax…Length` bound per category so callers can size buffers. These are the only methods implementers must provide. 2. **Convenience methods**: Self-allocating methods that call Try\* methods and manage buffers automatically. Provided via default interface implementations. -3. **Async variants**: Task-based async versions with `CancellationToken` support. The stream paths of the compression providers and of `AesEncryptionProvider`, along with `IHashProvider.TryHashAsync(Stream, ...)`, are genuinely asynchronous — real `ReadAsync`/`WriteAsync`, no thread held. The rest are still `Task.Run` wrappers over synchronous work via `ProviderHelpers.RunAsync()`; see issue #8. A provider makes its stream paths genuine by declaring the two `Try…Async(Stream, Stream, ...)` primitives itself, which replaces the default implementation; the four derived stream defaults compose over those primitives, so overriding two members converts all six. Span-destination async overloads do not exist — an `out` parameter cannot cross an async boundary. +3. **Async variants**: Task-based async versions with `CancellationToken` support. The stream paths of the compression providers and of `AesEncryptionProvider`, along with `IHashProvider.TryHashAsync(Stream, ...)` and `IKeyedHashProvider.TryHashAsync(ReadOnlyMemory, Stream, ...)`, are genuinely asynchronous — real `ReadAsync`/`WriteAsync`, no thread held. The rest are still `Task.Run` wrappers over synchronous work via `ProviderHelpers.RunAsync()`; see issue #8. A provider makes its stream paths genuine by declaring the two `Try…Async(Stream, Stream, ...)` primitives itself, which replaces the default implementation; the four derived stream defaults compose over those primitives, so overriding two members converts all six. Span-destination async overloads do not exist — an `out` parameter cannot cross an async boundary. Common patterns are centralized in `ProviderHelpers.cs`: @@ -103,6 +107,7 @@ Tests use **MSTest.Sdk** targeting net10.0 only. The test project (`Essentials.T - `HashProviderTests.cs` - Tests all 15 hash provider implementations - `IncrementalHashTests.cs` - Tests `CreateIncremental()` and async stream hashing across all 15 hash providers, asserting incremental output equals one-shot output +- `KeyedHashProviderTests.cs` - Tests all 3 HMAC keyed hash providers, `Verify`, and `FixedTimeComparison` - `CacheProviderTests.cs` - Tests cache operations including expiration - `CommandExecutorTests.cs` - Tests command execution - `EncodingProviderTests.cs` - Tests Base64 and Hex encoding diff --git a/DESCRIPTION.md b/DESCRIPTION.md index 82305b1..056407b 100644 --- a/DESCRIPTION.md +++ b/DESCRIPTION.md @@ -1 +1 @@ -A comprehensive .NET library providing high-performance interfaces and ready-to-use implementations for common cross-cutting concerns including compression (Gzip, Brotli, Deflate, ZLib), encoding (Base64, Hex), obfuscation (XOR, Caesar, bit-rotation, byte-reversal, Base64, Hex, and composable chains), encryption (AES), hashing (15 algorithms including SHA, MD5, CRC, FNV, XxHash), serialization (System.Text.Json, Newtonsoft.Json, YAML, TOML), caching, persistence, validation, logging, navigation, command execution, and filesystem access. Features zero-allocation Span-based operations, default interface implementations to minimize boilerplate, and async variants with CancellationToken support. Install everything with the ktsu.Essentials.All meta-package, or cherry-pick individual provider packages. +A comprehensive .NET library providing high-performance interfaces and ready-to-use implementations for common cross-cutting concerns including compression (Gzip, Brotli, Deflate, ZLib), encoding (Base64, Hex), obfuscation (XOR, Caesar, bit-rotation, byte-reversal, Base64, Hex, and composable chains), encryption (AES), hashing (15 algorithms including SHA, MD5, CRC, FNV, XxHash), keyed hashing and message authentication (HMAC-SHA256/384/512, with fixed-time tag verification), serialization (System.Text.Json, Newtonsoft.Json, YAML, TOML), caching, persistence, validation, logging, navigation, command execution, and filesystem access. Features zero-allocation Span-based operations, default interface implementations to minimize boilerplate, and async variants with CancellationToken support. Install everything with the ktsu.Essentials.All meta-package, or cherry-pick individual provider packages. diff --git a/Essentials.All/Essentials.All.csproj b/Essentials.All/Essentials.All.csproj index 4218310..28c3938 100644 --- a/Essentials.All/Essentials.All.csproj +++ b/Essentials.All/Essentials.All.csproj @@ -36,6 +36,9 @@ + + + diff --git a/Essentials.All/ServiceCollectionExtensions.cs b/Essentials.All/ServiceCollectionExtensions.cs index 9e9f327..a4b4529 100644 --- a/Essentials.All/ServiceCollectionExtensions.cs +++ b/Essentials.All/ServiceCollectionExtensions.cs @@ -26,6 +26,9 @@ namespace ktsu.Essentials.All; using ktsu.Essentials.HashProviders.XxHash3; using ktsu.Essentials.HashProviders.XxHash32; using ktsu.Essentials.HashProviders.XxHash64; +using ktsu.Essentials.KeyedHashProviders.HmacSha256; +using ktsu.Essentials.KeyedHashProviders.HmacSha384; +using ktsu.Essentials.KeyedHashProviders.HmacSha512; using ktsu.Essentials.LoggingProviders.Console; using ktsu.Essentials.NavigationProviders.InMemory; using ktsu.Essentials.ObfuscationProviders.Base64; @@ -73,6 +76,7 @@ public static IServiceCollection AddEssentials(this IServiceCollection services) .AddEncryptionProviders() .AddFileSystemProviders() .AddHashProviders() + .AddKeyedHashProviders() .AddLoggingProviders() .AddNavigationProviders() .AddObfuscationProviders() @@ -155,6 +159,21 @@ public static IServiceCollection AddHashProviders(this IServiceCollection servic .AddXxHash128HashProvider(); } + /// + /// Registers every bundled keyed hash provider. + /// + /// The service collection to add the providers to. + /// The same service collection, to allow chaining. + public static IServiceCollection AddKeyedHashProviders(this IServiceCollection services) + { + Ensure.NotNull(services); + + return services + .AddHmacSha256KeyedHashProvider() + .AddHmacSha384KeyedHashProvider() + .AddHmacSha512KeyedHashProvider(); + } + /// /// Registers every bundled obfuscation provider that has a usable default configuration. /// diff --git a/Essentials.EncryptionProviders.Aes/AesEncryptionProvider.cs b/Essentials.EncryptionProviders.Aes/AesEncryptionProvider.cs index 0af94be..573ba1c 100644 --- a/Essentials.EncryptionProviders.Aes/AesEncryptionProvider.cs +++ b/Essentials.EncryptionProviders.Aes/AesEncryptionProvider.cs @@ -16,6 +16,18 @@ namespace ktsu.Essentials.EncryptionProviders.Aes; /// This type is stateless and safe to share across threads — every operation creates its own /// instance from the caller-supplied key and IV. /// It is therefore safe to register as a singleton. +/// +/// This provider is AES in CBC mode with PKCS7 padding, which is what Aes.Create() defaults to. +/// CBC ciphertext is malleable: an attacker who can modify it can make predictable changes to the +/// decrypted plaintext without knowing the key. Decryption reports padding failures, so a caller who +/// decrypts attacker-supplied input and reveals whether it parsed becomes a padding oracle. +/// +/// +/// Authenticate the initialization vector and the ciphertext together before decrypting them. CBC +/// recovers the first plaintext block as the initialization vector XORed with the decryption of the +/// first ciphertext block, so a tag covering only the ciphertext still leaves that block rewritable. +/// See the remarks on . +/// /// public class AesEncryptionProvider : IEncryptionProvider { diff --git a/Essentials.KeyedHashProviders.HmacSha256/Essentials.KeyedHashProviders.HmacSha256.csproj b/Essentials.KeyedHashProviders.HmacSha256/Essentials.KeyedHashProviders.HmacSha256.csproj new file mode 100644 index 0000000..0a26e7f --- /dev/null +++ b/Essentials.KeyedHashProviders.HmacSha256/Essentials.KeyedHashProviders.HmacSha256.csproj @@ -0,0 +1,23 @@ + + + + + net10.0;net9.0;net8.0;net7.0;net6.0;netstandard2.1 + true + + + + + + + + + + + + + + + + + diff --git a/Essentials.KeyedHashProviders.HmacSha256/HmacSha256KeyedHashProvider.cs b/Essentials.KeyedHashProviders.HmacSha256/HmacSha256KeyedHashProvider.cs new file mode 100644 index 0000000..50f68f3 --- /dev/null +++ b/Essentials.KeyedHashProviders.HmacSha256/HmacSha256KeyedHashProvider.cs @@ -0,0 +1,36 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Essentials.KeyedHashProviders.HmacSha256; + +using System; +using System.IO; +using System.Security.Cryptography; +using ktsu.Essentials; + +/// +/// A keyed hash provider that uses HMAC-SHA-256 to authenticate data. +/// +/// +/// This type is stateless and safe to share across threads, because the key is supplied per call +/// rather than held in a field. Every operation delegates to the shared HMAC core, which owns key +/// copying and zeroing. +/// +public class HmacSha256KeyedHashProvider : IKeyedHashProvider +{ + /// + /// The length of the HMAC-SHA-256 tag in bytes (32 bytes / 256 bits). + /// + public int HashLengthBytes => 32; + + /// + public bool TryHash(ReadOnlySpan key, ReadOnlySpan data, Span destination, out int bytesWritten) + => HmacKeyedHashCore.TryHash(HashAlgorithmName.SHA256, HashLengthBytes, key, data, destination, out bytesWritten); + + /// + public bool TryHash(ReadOnlySpan key, Stream data, Span destination, out int bytesWritten) + => HmacKeyedHashCore.TryHash(HashAlgorithmName.SHA256, HashLengthBytes, key, data, destination, out bytesWritten); + + /// + public IIncrementalHash CreateIncremental(ReadOnlySpan key) + => HmacKeyedHashCore.CreateIncremental(HashAlgorithmName.SHA256, HashLengthBytes, key); +} diff --git a/Essentials.KeyedHashProviders.HmacSha256/ServiceCollectionExtensions.cs b/Essentials.KeyedHashProviders.HmacSha256/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..0d5847a --- /dev/null +++ b/Essentials.KeyedHashProviders.HmacSha256/ServiceCollectionExtensions.cs @@ -0,0 +1,32 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Essentials.KeyedHashProviders.HmacSha256; + +using ktsu.Essentials; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +/// +/// Dependency injection registration for the HMAC-SHA-256 keyed hashing provider. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers the HMAC-SHA-256 keyed hashing provider. + /// + /// + /// The provider is registered as a singleton, both as its concrete type and as an additional + /// in the resolvable set, so it can be resolved either way. The + /// container constructs and owns each registration. Calling this more than once is a no-op. + /// + /// The service collection to add the provider to. + /// The same service collection, to allow chaining. + public static IServiceCollection AddHmacSha256KeyedHashProvider(this IServiceCollection services) + { + Ensure.NotNull(services); + + services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + return services; + } +} diff --git a/Essentials.KeyedHashProviders.HmacSha384/Essentials.KeyedHashProviders.HmacSha384.csproj b/Essentials.KeyedHashProviders.HmacSha384/Essentials.KeyedHashProviders.HmacSha384.csproj new file mode 100644 index 0000000..0a26e7f --- /dev/null +++ b/Essentials.KeyedHashProviders.HmacSha384/Essentials.KeyedHashProviders.HmacSha384.csproj @@ -0,0 +1,23 @@ + + + + + net10.0;net9.0;net8.0;net7.0;net6.0;netstandard2.1 + true + + + + + + + + + + + + + + + + + diff --git a/Essentials.KeyedHashProviders.HmacSha384/HmacSha384KeyedHashProvider.cs b/Essentials.KeyedHashProviders.HmacSha384/HmacSha384KeyedHashProvider.cs new file mode 100644 index 0000000..4d2cf30 --- /dev/null +++ b/Essentials.KeyedHashProviders.HmacSha384/HmacSha384KeyedHashProvider.cs @@ -0,0 +1,36 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Essentials.KeyedHashProviders.HmacSha384; + +using System; +using System.IO; +using System.Security.Cryptography; +using ktsu.Essentials; + +/// +/// A keyed hash provider that uses HMAC-SHA-384 to authenticate data. +/// +/// +/// This type is stateless and safe to share across threads, because the key is supplied per call +/// rather than held in a field. Every operation delegates to the shared HMAC core, which owns key +/// copying and zeroing. +/// +public class HmacSha384KeyedHashProvider : IKeyedHashProvider +{ + /// + /// The length of the HMAC-SHA-384 tag in bytes (48 bytes / 384 bits). + /// + public int HashLengthBytes => 48; + + /// + public bool TryHash(ReadOnlySpan key, ReadOnlySpan data, Span destination, out int bytesWritten) + => HmacKeyedHashCore.TryHash(HashAlgorithmName.SHA384, HashLengthBytes, key, data, destination, out bytesWritten); + + /// + public bool TryHash(ReadOnlySpan key, Stream data, Span destination, out int bytesWritten) + => HmacKeyedHashCore.TryHash(HashAlgorithmName.SHA384, HashLengthBytes, key, data, destination, out bytesWritten); + + /// + public IIncrementalHash CreateIncremental(ReadOnlySpan key) + => HmacKeyedHashCore.CreateIncremental(HashAlgorithmName.SHA384, HashLengthBytes, key); +} diff --git a/Essentials.KeyedHashProviders.HmacSha384/ServiceCollectionExtensions.cs b/Essentials.KeyedHashProviders.HmacSha384/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..4f037aa --- /dev/null +++ b/Essentials.KeyedHashProviders.HmacSha384/ServiceCollectionExtensions.cs @@ -0,0 +1,32 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Essentials.KeyedHashProviders.HmacSha384; + +using ktsu.Essentials; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +/// +/// Dependency injection registration for the HMAC-SHA-384 keyed hashing provider. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers the HMAC-SHA-384 keyed hashing provider. + /// + /// + /// The provider is registered as a singleton, both as its concrete type and as an additional + /// in the resolvable set, so it can be resolved either way. The + /// container constructs and owns each registration. Calling this more than once is a no-op. + /// + /// The service collection to add the provider to. + /// The same service collection, to allow chaining. + public static IServiceCollection AddHmacSha384KeyedHashProvider(this IServiceCollection services) + { + Ensure.NotNull(services); + + services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + return services; + } +} diff --git a/Essentials.KeyedHashProviders.HmacSha512/Essentials.KeyedHashProviders.HmacSha512.csproj b/Essentials.KeyedHashProviders.HmacSha512/Essentials.KeyedHashProviders.HmacSha512.csproj new file mode 100644 index 0000000..0a26e7f --- /dev/null +++ b/Essentials.KeyedHashProviders.HmacSha512/Essentials.KeyedHashProviders.HmacSha512.csproj @@ -0,0 +1,23 @@ + + + + + net10.0;net9.0;net8.0;net7.0;net6.0;netstandard2.1 + true + + + + + + + + + + + + + + + + + diff --git a/Essentials.KeyedHashProviders.HmacSha512/HmacSha512KeyedHashProvider.cs b/Essentials.KeyedHashProviders.HmacSha512/HmacSha512KeyedHashProvider.cs new file mode 100644 index 0000000..1899431 --- /dev/null +++ b/Essentials.KeyedHashProviders.HmacSha512/HmacSha512KeyedHashProvider.cs @@ -0,0 +1,36 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Essentials.KeyedHashProviders.HmacSha512; + +using System; +using System.IO; +using System.Security.Cryptography; +using ktsu.Essentials; + +/// +/// A keyed hash provider that uses HMAC-SHA-512 to authenticate data. +/// +/// +/// This type is stateless and safe to share across threads, because the key is supplied per call +/// rather than held in a field. Every operation delegates to the shared HMAC core, which owns key +/// copying and zeroing. +/// +public class HmacSha512KeyedHashProvider : IKeyedHashProvider +{ + /// + /// The length of the HMAC-SHA-512 tag in bytes (64 bytes / 512 bits). + /// + public int HashLengthBytes => 64; + + /// + public bool TryHash(ReadOnlySpan key, ReadOnlySpan data, Span destination, out int bytesWritten) + => HmacKeyedHashCore.TryHash(HashAlgorithmName.SHA512, HashLengthBytes, key, data, destination, out bytesWritten); + + /// + public bool TryHash(ReadOnlySpan key, Stream data, Span destination, out int bytesWritten) + => HmacKeyedHashCore.TryHash(HashAlgorithmName.SHA512, HashLengthBytes, key, data, destination, out bytesWritten); + + /// + public IIncrementalHash CreateIncremental(ReadOnlySpan key) + => HmacKeyedHashCore.CreateIncremental(HashAlgorithmName.SHA512, HashLengthBytes, key); +} diff --git a/Essentials.KeyedHashProviders.HmacSha512/ServiceCollectionExtensions.cs b/Essentials.KeyedHashProviders.HmacSha512/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..ae7c8d3 --- /dev/null +++ b/Essentials.KeyedHashProviders.HmacSha512/ServiceCollectionExtensions.cs @@ -0,0 +1,32 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Essentials.KeyedHashProviders.HmacSha512; + +using ktsu.Essentials; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +/// +/// Dependency injection registration for the HMAC-SHA-512 keyed hashing provider. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers the HMAC-SHA-512 keyed hashing provider. + /// + /// + /// The provider is registered as a singleton, both as its concrete type and as an additional + /// in the resolvable set, so it can be resolved either way. The + /// container constructs and owns each registration. Calling this more than once is a no-op. + /// + /// The service collection to add the provider to. + /// The same service collection, to allow chaining. + public static IServiceCollection AddHmacSha512KeyedHashProvider(this IServiceCollection services) + { + Ensure.NotNull(services); + + services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + return services; + } +} diff --git a/Essentials.Tests/Essentials.Tests.csproj b/Essentials.Tests/Essentials.Tests.csproj index 9d1c68f..c014bbc 100644 --- a/Essentials.Tests/Essentials.Tests.csproj +++ b/Essentials.Tests/Essentials.Tests.csproj @@ -43,6 +43,9 @@ + + + diff --git a/Essentials.Tests/KeyedHashProviderTests.cs b/Essentials.Tests/KeyedHashProviderTests.cs new file mode 100644 index 0000000..68b8648 --- /dev/null +++ b/Essentials.Tests/KeyedHashProviderTests.cs @@ -0,0 +1,538 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Essentials.Tests; + +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using ktsu.Essentials; +using ktsu.Essentials.All; +using ktsu.Essentials.KeyedHashProviders.HmacSha256; +using ktsu.Essentials.KeyedHashProviders.HmacSha384; +using ktsu.Essentials.KeyedHashProviders.HmacSha512; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[TestClass] +public class KeyedHashProviderTests +{ + #region FixedTimeEquals + + [TestMethod] + public void FixedTimeEquals_Matches_Identical_Spans() + { + byte[] left = [1, 2, 3, 4]; + byte[] right = [1, 2, 3, 4]; + + Assert.IsTrue(FixedTimeComparison.FixedTimeEquals(left, right)); + } + + [TestMethod] + public void FixedTimeEquals_Rejects_Single_Bit_Difference() + { + byte[] left = [1, 2, 3, 4]; + byte[] right = [1, 2, 3, 5]; + + Assert.IsFalse(FixedTimeComparison.FixedTimeEquals(left, right)); + } + + [TestMethod] + public void FixedTimeEquals_Rejects_Different_Lengths() + { + byte[] left = [1, 2, 3, 4]; + byte[] right = [1, 2, 3]; + + Assert.IsFalse(FixedTimeComparison.FixedTimeEquals(left, right)); + } + + [TestMethod] + public void FixedTimeEquals_Matches_Empty_Spans() + { + Assert.IsTrue(FixedTimeComparison.FixedTimeEquals([], [])); + } + + #endregion + + #region Default interface implementations + + /// + /// A minimal implementer supplying only the two required primitives, which is what a third-party + /// implementer writes. Exercising the defaults through this proves they do not secretly depend on + /// anything a real provider overrides. + /// + /// + /// The "MAC" is deliberately trivial and is not a real construction: each output byte is the + /// running sum of the data XORed with a key byte. It only needs to be deterministic, key-dependent, + /// and data-dependent for these tests to mean something. + /// + private sealed class FakeKeyedHashProvider : IKeyedHashProvider + { + public int HashLengthBytes => 8; + + public bool TryHash(ReadOnlySpan key, ReadOnlySpan data, Span destination, out int bytesWritten) + { + bytesWritten = 0; + + if (destination.Length < HashLengthBytes) + { + return false; + } + + for (int i = 0; i < HashLengthBytes; i++) + { + byte accumulator = key.Length > 0 ? key[i % key.Length] : (byte)0; + for (int j = 0; j < data.Length; j++) + { + accumulator = (byte)(accumulator + data[j] + i); + } + + destination[i] = accumulator; + } + + bytesWritten = HashLengthBytes; + return true; + } + + public bool TryHash(ReadOnlySpan key, Stream data, Span destination, out int bytesWritten) + { + bytesWritten = 0; + + if (data is null) + { + return false; + } + + using MemoryStream copy = new(); + data.CopyTo(copy); + return TryHash(key, copy.ToArray(), destination, out bytesWritten); + } + } + + private static readonly byte[] FakeKey = Encoding.UTF8.GetBytes("fake-key"); + private static readonly byte[] FakePayload = Encoding.UTF8.GetBytes("the quick brown fox"); + + [TestMethod] + public void Defaults_Hash_Span_Matches_TryHash() + { + IKeyedHashProvider provider = new FakeKeyedHashProvider(); + byte[] expected = new byte[provider.HashLengthBytes]; + Assert.IsTrue(provider.TryHash(FakeKey, FakePayload, expected, out int written)); + Assert.AreEqual(provider.HashLengthBytes, written); + + byte[] actual = provider.Hash(FakeKey, FakePayload); + + CollectionAssert.AreEqual(expected, actual); + } + + [TestMethod] + public void Defaults_Hash_Stream_Matches_Hash_Span() + { + IKeyedHashProvider provider = new FakeKeyedHashProvider(); + using MemoryStream stream = new(FakePayload); + + byte[] fromStream = provider.Hash(FakeKey, stream); + + CollectionAssert.AreEqual(provider.Hash(FakeKey, FakePayload), fromStream); + } + + [TestMethod] + public void Defaults_Hash_String_Matches_Utf8_Bytes() + { + IKeyedHashProvider provider = new FakeKeyedHashProvider(); + + byte[] fromString = provider.Hash(FakeKey, "the quick brown fox"); + + CollectionAssert.AreEqual(provider.Hash(FakeKey, FakePayload), fromString); + } + + [TestMethod] + public void Defaults_CreateIncremental_Matches_One_Shot() + { + IKeyedHashProvider provider = new FakeKeyedHashProvider(); + using IIncrementalHash incremental = provider.CreateIncremental(FakeKey); + incremental.Append(FakePayload.AsSpan(0, 5)); + incremental.Append(FakePayload.AsSpan(5)); + + byte[] actual = incremental.GetHashAndReset(); + + CollectionAssert.AreEqual(provider.Hash(FakeKey, FakePayload), actual); + } + + [TestMethod] + public async Task Defaults_TryHashAsync_Matches_One_Shot() + { + IKeyedHashProvider provider = new FakeKeyedHashProvider(); + using MemoryStream stream = new(FakePayload); + byte[] actual = new byte[provider.HashLengthBytes]; + + bool ok = await provider.TryHashAsync(FakeKey, stream, actual).ConfigureAwait(false); + + Assert.IsTrue(ok); + CollectionAssert.AreEqual(provider.Hash(FakeKey, FakePayload), actual); + } + + [TestMethod] + public async Task Defaults_HashAsync_Memory_Matches_One_Shot() + { + IKeyedHashProvider provider = new FakeKeyedHashProvider(); + + byte[] actual = await provider.HashAsync(FakeKey, FakePayload).ConfigureAwait(false); + + CollectionAssert.AreEqual(provider.Hash(FakeKey, FakePayload), actual); + } + + [TestMethod] + public async Task Defaults_HashAsync_Stream_Matches_One_Shot() + { + IKeyedHashProvider provider = new FakeKeyedHashProvider(); + using MemoryStream stream = new(FakePayload); + + byte[] actual = await provider.HashAsync(FakeKey, stream).ConfigureAwait(false); + + CollectionAssert.AreEqual(provider.Hash(FakeKey, FakePayload), actual); + } + + [TestMethod] + public void Defaults_TryHash_Rejects_Undersized_Destination() + { + IKeyedHashProvider provider = new FakeKeyedHashProvider(); + byte[] tooSmall = new byte[provider.HashLengthBytes - 1]; + + Assert.IsFalse(provider.TryHash(FakeKey, FakePayload, tooSmall, out int written)); + Assert.AreEqual(0, written); + } + + [TestMethod] + public void Defaults_Verify_Accepts_Correct_Tag() + { + IKeyedHashProvider provider = new FakeKeyedHashProvider(); + byte[] tag = provider.Hash(FakeKey, FakePayload); + + Assert.IsTrue(provider.Verify(FakeKey, FakePayload, tag)); + } + + [TestMethod] + public void Defaults_Verify_Rejects_Flipped_Tag_Bit() + { + IKeyedHashProvider provider = new FakeKeyedHashProvider(); + byte[] tag = provider.Hash(FakeKey, FakePayload); + tag[0] ^= 0x01; + + Assert.IsFalse(provider.Verify(FakeKey, FakePayload, tag)); + } + + [TestMethod] + public void Defaults_Verify_Rejects_Wrong_Key() + { + IKeyedHashProvider provider = new FakeKeyedHashProvider(); + byte[] tag = provider.Hash(FakeKey, FakePayload); + byte[] wrongKey = Encoding.UTF8.GetBytes("other-key"); + + Assert.IsFalse(provider.Verify(wrongKey, FakePayload, tag)); + } + + [TestMethod] + public void Defaults_Verify_Rejects_Truncated_Tag() + { + IKeyedHashProvider provider = new FakeKeyedHashProvider(); + byte[] tag = provider.Hash(FakeKey, FakePayload); + + Assert.IsFalse(provider.Verify(FakeKey, FakePayload, tag.AsSpan(0, tag.Length - 1))); + } + + #endregion + + #region HMAC-SHA256 known answer vectors + + private static byte[] FromHex(string hex) + { + byte[] bytes = new byte[hex.Length / 2]; + for (int i = 0; i < bytes.Length; i++) + { + bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); + } + + return bytes; + } + + [TestMethod] + public void HmacSha256_Rfc4231_Case1() + { + IKeyedHashProvider provider = new HmacSha256KeyedHashProvider(); + byte[] key = [.. Enumerable.Repeat((byte)0x0b, 20)]; + byte[] data = Encoding.UTF8.GetBytes("Hi There"); + + byte[] actual = provider.Hash(key, data); + + CollectionAssert.AreEqual( + FromHex("b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"), + actual); + } + + [TestMethod] + public void HmacSha256_Rfc4231_Case2() + { + IKeyedHashProvider provider = new HmacSha256KeyedHashProvider(); + byte[] key = Encoding.UTF8.GetBytes("Jefe"); + byte[] data = Encoding.UTF8.GetBytes("what do ya want for nothing?"); + + byte[] actual = provider.Hash(key, data); + + CollectionAssert.AreEqual( + FromHex("5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"), + actual); + } + + [TestMethod] + public void HmacSha256_Rfc4231_Case6_Oversized_Key() + { + IKeyedHashProvider provider = new HmacSha256KeyedHashProvider(); + byte[] key = [.. Enumerable.Repeat((byte)0xaa, 131)]; + byte[] data = Encoding.UTF8.GetBytes("Test Using Larger Than Block-Size Key - Hash Key First"); + + byte[] actual = provider.Hash(key, data); + + CollectionAssert.AreEqual( + FromHex("60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"), + actual); + } + + [TestMethod] + public void HmacSha256_Agrees_With_Bcl() + { + IKeyedHashProvider provider = new HmacSha256KeyedHashProvider(); + byte[] key = Encoding.UTF8.GetBytes("a key of some length"); + byte[] data = Encoding.UTF8.GetBytes("a payload to authenticate"); + + byte[] actual = provider.Hash(key, data); + + using HMACSHA256 reference = new(key); + CollectionAssert.AreEqual(reference.ComputeHash(data), actual); + } + + [TestMethod] + public void HmacSha256_All_Four_Paths_Agree() + { + IKeyedHashProvider provider = new HmacSha256KeyedHashProvider(); + byte[] key = Encoding.UTF8.GetBytes("agreement key"); + byte[] data = Encoding.UTF8.GetBytes("a payload long enough to span several appends"); + byte[] oneShot = provider.Hash(key, data); + + using MemoryStream stream = new(data); + byte[] fromStream = provider.Hash(key, stream); + + using IIncrementalHash incremental = provider.CreateIncremental(key); + incremental.Append(data.AsSpan(0, 7)); + incremental.Append(data.AsSpan(7, 20)); + incremental.Append(data.AsSpan(27)); + byte[] fromIncremental = incremental.GetHashAndReset(); + + CollectionAssert.AreEqual(oneShot, fromStream); + CollectionAssert.AreEqual(oneShot, fromIncremental); + } + + [TestMethod] + public async Task HmacSha256_Async_Agrees_With_One_Shot() + { + IKeyedHashProvider provider = new HmacSha256KeyedHashProvider(); + byte[] key = Encoding.UTF8.GetBytes("async key"); + byte[] data = Encoding.UTF8.GetBytes("a payload to authenticate asynchronously"); + using MemoryStream stream = new(data); + + byte[] fromAsync = await provider.HashAsync(key, stream).ConfigureAwait(false); + + CollectionAssert.AreEqual(provider.Hash(key, data), fromAsync); + } + + [TestMethod] + public void HmacSha256_Reports_Exact_Length_And_Leaves_Tail_Untouched() + { + HmacSha256KeyedHashProvider provider = new(); + byte[] key = Encoding.UTF8.GetBytes("contract key"); + byte[] data = Encoding.UTF8.GetBytes("contract payload"); + byte[] buffer = new byte[provider.HashLengthBytes + 16]; + buffer.AsSpan().Fill(0xCD); + + Assert.IsTrue(provider.TryHash(key, data, buffer, out int written)); + + Assert.AreEqual(provider.HashLengthBytes, written); + foreach (byte b in buffer.AsSpan(written).ToArray()) + { + Assert.AreEqual(0xCD, b, "the tail of the caller's buffer must not be touched"); + } + } + + #endregion + + #region HMAC-SHA384 and HMAC-SHA512 known answer vectors + + [TestMethod] + public void HmacSha384_Rfc4231_Case1() + { + IKeyedHashProvider provider = new HmacSha384KeyedHashProvider(); + byte[] key = [.. Enumerable.Repeat((byte)0x0b, 20)]; + + byte[] actual = provider.Hash(key, Encoding.UTF8.GetBytes("Hi There")); + + CollectionAssert.AreEqual( + FromHex("afd03944d84895626b0825f4ab46907f15f9dadbe4101ec682aa034c7cebc59cfaea9ea9076ede7f4af152e8b2fa9cb6"), + actual); + } + + [TestMethod] + public void HmacSha384_Rfc4231_Case2() + { + IKeyedHashProvider provider = new HmacSha384KeyedHashProvider(); + byte[] key = Encoding.UTF8.GetBytes("Jefe"); + + byte[] actual = provider.Hash(key, Encoding.UTF8.GetBytes("what do ya want for nothing?")); + + CollectionAssert.AreEqual( + FromHex("af45d2e376484031617f78d2b58a6b1b9c7ef464f5a01b47e42ec3736322445e8e2240ca5e69e2c78b3239ecfab21649"), + actual); + } + + [TestMethod] + public void HmacSha384_Rfc4231_Case6_Oversized_Key() + { + IKeyedHashProvider provider = new HmacSha384KeyedHashProvider(); + byte[] key = [.. Enumerable.Repeat((byte)0xaa, 131)]; + + byte[] actual = provider.Hash(key, Encoding.UTF8.GetBytes("Test Using Larger Than Block-Size Key - Hash Key First")); + + CollectionAssert.AreEqual( + FromHex("4ece084485813e9088d2c63a041bc5b44f9ef1012a2b588f3cd11f05033ac4c60c2ef6ab4030fe8296248df163f44952"), + actual); + } + + [TestMethod] + public void HmacSha512_Rfc4231_Case1() + { + IKeyedHashProvider provider = new HmacSha512KeyedHashProvider(); + byte[] key = [.. Enumerable.Repeat((byte)0x0b, 20)]; + + byte[] actual = provider.Hash(key, Encoding.UTF8.GetBytes("Hi There")); + + CollectionAssert.AreEqual( + FromHex("87aa7cdea5ef619d4ff0b4241a1d6cb02379f4e2ce4ec2787ad0b30545e17cdedaa833b7d6b8a702038b274eaea3f4e4be9d914eeb61f1702e696c203a126854"), + actual); + } + + [TestMethod] + public void HmacSha512_Rfc4231_Case2() + { + IKeyedHashProvider provider = new HmacSha512KeyedHashProvider(); + byte[] key = Encoding.UTF8.GetBytes("Jefe"); + + byte[] actual = provider.Hash(key, Encoding.UTF8.GetBytes("what do ya want for nothing?")); + + CollectionAssert.AreEqual( + FromHex("164b7a7bfcf819e2e395fbe73b56e0a387bd64222e831fd610270cd7ea2505549758bf75c05a994a6d034f65f8f0e6fdcaeab1a34d4a6b4b636e070a38bce737"), + actual); + } + + [TestMethod] + public void HmacSha512_Rfc4231_Case6_Oversized_Key() + { + IKeyedHashProvider provider = new HmacSha512KeyedHashProvider(); + byte[] key = [.. Enumerable.Repeat((byte)0xaa, 131)]; + + byte[] actual = provider.Hash(key, Encoding.UTF8.GetBytes("Test Using Larger Than Block-Size Key - Hash Key First")); + + CollectionAssert.AreEqual( + FromHex("80b24263c7c1a3ebb71493c1dd7be8b49b46d1f41b4aeec1121b013783f8f3526b56d037e05f2598bd0fd2215d6a1e5295e64f73f63f0aec8b915a985d786598"), + actual); + } + + [TestMethod] + public void HmacSha384_And_512_Report_Their_Tag_Lengths() + { + Assert.AreEqual(48, new HmacSha384KeyedHashProvider().HashLengthBytes); + Assert.AreEqual(64, new HmacSha512KeyedHashProvider().HashLengthBytes); + } + + [TestMethod] + public void HmacSha384_Stream_And_Incremental_Paths_Agree_With_Rfc4231() + { + IKeyedHashProvider provider = new HmacSha384KeyedHashProvider(); + byte[] key = [.. Enumerable.Repeat((byte)0x0b, 20)]; + byte[] data = Encoding.UTF8.GetBytes("Hi There"); + byte[] expected = FromHex("afd03944d84895626b0825f4ab46907f15f9dadbe4101ec682aa034c7cebc59cfaea9ea9076ede7f4af152e8b2fa9cb6"); + + using MemoryStream stream = new(data); + byte[] fromStream = provider.Hash(key, stream); + + using IIncrementalHash incremental = provider.CreateIncremental(key); + incremental.Append(data.AsSpan(0, 2)); + incremental.Append(data.AsSpan(2)); + byte[] fromIncremental = incremental.GetHashAndReset(); + + CollectionAssert.AreEqual(expected, fromStream); + CollectionAssert.AreEqual(expected, fromIncremental); + } + + [TestMethod] + public void HmacSha512_Stream_And_Incremental_Paths_Agree_With_Rfc4231() + { + IKeyedHashProvider provider = new HmacSha512KeyedHashProvider(); + byte[] key = [.. Enumerable.Repeat((byte)0x0b, 20)]; + byte[] data = Encoding.UTF8.GetBytes("Hi There"); + byte[] expected = FromHex("87aa7cdea5ef619d4ff0b4241a1d6cb02379f4e2ce4ec2787ad0b30545e17cdedaa833b7d6b8a702038b274eaea3f4e4be9d914eeb61f1702e696c203a126854"); + + using MemoryStream stream = new(data); + byte[] fromStream = provider.Hash(key, stream); + + using IIncrementalHash incremental = provider.CreateIncremental(key); + incremental.Append(data.AsSpan(0, 2)); + incremental.Append(data.AsSpan(2)); + byte[] fromIncremental = incremental.GetHashAndReset(); + + CollectionAssert.AreEqual(expected, fromStream); + CollectionAssert.AreEqual(expected, fromIncremental); + } + + #endregion + + #region Dependency injection + + [TestMethod] + public void AddKeyedHashProviders_Registers_All_Three() + { + ServiceCollection services = new(); + services.AddKeyedHashProviders(); + using ServiceProvider provider = services.BuildServiceProvider(); + + IKeyedHashProvider[] providers = [.. provider.GetServices()]; + + Assert.AreEqual(3, providers.Length); + Assert.AreEqual(1, providers.Count(p => p.HashLengthBytes == 32)); + Assert.AreEqual(1, providers.Count(p => p.HashLengthBytes == 48)); + Assert.AreEqual(1, providers.Count(p => p.HashLengthBytes == 64)); + } + + [TestMethod] + public void AddKeyedHashProviders_Resolves_Concrete_Types() + { + ServiceCollection services = new(); + services.AddKeyedHashProviders(); + using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.IsNotNull(provider.GetService()); + Assert.IsNotNull(provider.GetService()); + Assert.IsNotNull(provider.GetService()); + } + + [TestMethod] + public void AddEssentials_Includes_Keyed_Hash_Providers() + { + ServiceCollection services = new(); + services.AddEssentials(); + using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.AreEqual(3, provider.GetServices().Count()); + } + + #endregion +} diff --git a/Essentials.Tests/PooledBufferScrubbingTests.cs b/Essentials.Tests/PooledBufferScrubbingTests.cs index cf65afb..bc0d148 100644 --- a/Essentials.Tests/PooledBufferScrubbingTests.cs +++ b/Essentials.Tests/PooledBufferScrubbingTests.cs @@ -8,12 +8,13 @@ namespace ktsu.Essentials.Tests; using System.Threading.Tasks; using ktsu.Essentials.EncodingProviders.Hex; using ktsu.Essentials.HashProviders.SHA256; +using ktsu.Essentials.KeyedHashProviders.HmacSha256; using Microsoft.VisualStudio.TestTools.UnitTesting; /// /// Buffers rented from .Shared are process-wide: whatever a provider leaves /// in one stays there until some later renter overwrites it, and the next renter is arbitrary other -/// code in the same process. These tests pin that the two pooled call sites hand their buffers back +/// code in the same process. These tests pin that the four pooled call sites hand their buffers back /// scrubbed rather than carrying hashed or transformed data out with them. /// /// @@ -33,7 +34,9 @@ public class PooledBufferScrubbingTests private const byte Sentinel = 0xCC; /// - /// The buffer size rents for its read loop. + /// The buffer size each of the four pooled read loops rents: , + /// , and the sync and stream HMAC paths in + /// HmacKeyedHashCore. /// private const int HashReadBufferLength = 81920; @@ -92,4 +95,34 @@ public void ExecuteToExactArrayReturnsItsScratchBufferScrubbed() AssertScrubbed(scratch); } + + [TestMethod] + [DoNotParallelize] + public async Task KeyedHashTryHashAsyncReturnsItsReadBufferScrubbedAsync() + { + IKeyedHashProvider provider = new HmacSha256KeyedHashProvider(); + byte[] scratch = SeedPoolWithSentinelBuffer(HashReadBufferLength); + byte[] key = BuildPayload(32); + using MemoryStream data = new(BuildPayload(4096)); + byte[] destination = new byte[provider.HashLengthBytes]; + + _ = await provider.TryHashAsync(key, data, destination).ConfigureAwait(false); + + AssertScrubbed(scratch); + } + + [TestMethod] + [DoNotParallelize] + public void KeyedHashStreamTryHashReturnsItsReadBufferScrubbed() + { + HmacSha256KeyedHashProvider provider = new(); + byte[] scratch = SeedPoolWithSentinelBuffer(HashReadBufferLength); + byte[] key = BuildPayload(32); + using MemoryStream data = new(BuildPayload(4096)); + byte[] destination = new byte[provider.HashLengthBytes]; + + _ = provider.TryHash(key, data, destination, out _); + + AssertScrubbed(scratch); + } } diff --git a/Essentials.slnx b/Essentials.slnx index c5b7432..f90ebec 100644 --- a/Essentials.slnx +++ b/Essentials.slnx @@ -53,6 +53,11 @@ + + + + + diff --git a/Essentials/BufferingKeyedIncrementalHash.cs b/Essentials/BufferingKeyedIncrementalHash.cs new file mode 100644 index 0000000..439ff5c --- /dev/null +++ b/Essentials/BufferingKeyedIncrementalHash.cs @@ -0,0 +1,65 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Essentials; + +using System; +using System.IO; +using System.Security.Cryptography; + +/// +/// An that accumulates every appended byte and hashes the result in +/// one pass, for keyed hash providers that do not supply a genuinely incremental implementation. +/// +/// +/// Correct for any provider, but it holds the whole input in memory, which is the cost incremental +/// hashing exists to avoid. It backs the default body of +/// so that implementers need only write the two +/// required primitives; providers are expected to override it. The key is copied on construction and +/// zeroed on disposal, so the instance must be disposed. +/// +internal sealed class BufferingKeyedIncrementalHash : IIncrementalHash +{ + private readonly IKeyedHashProvider provider; + private readonly byte[] keyCopy; + private readonly MemoryStream buffer = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The provider whose one-shot stream hashing produces the digest. + /// The key, copied into this instance and zeroed on disposal. + internal BufferingKeyedIncrementalHash(IKeyedHashProvider keyedHashProvider, ReadOnlySpan key) + { + provider = keyedHashProvider; + keyCopy = key.ToArray(); + } + + /// + public int HashLengthBytes => provider.HashLengthBytes; + + /// + public void Append(ReadOnlySpan data) => buffer.Write(data); + + /// + public bool TryGetHashAndReset(Span destination, out int bytesWritten) + { + bytesWritten = 0; + + if (destination.Length < HashLengthBytes) + { + return false; + } + + buffer.Position = 0; + bool hashed = provider.TryHash(keyCopy, buffer, destination, out bytesWritten); + buffer.SetLength(0); + return hashed; + } + + /// + public void Dispose() + { + CryptographicOperations.ZeroMemory(keyCopy); + buffer.Dispose(); + } +} diff --git a/Essentials/FixedTimeComparison.cs b/Essentials/FixedTimeComparison.cs new file mode 100644 index 0000000..b77a20d --- /dev/null +++ b/Essentials/FixedTimeComparison.cs @@ -0,0 +1,35 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Essentials; + +using System; +using System.Security.Cryptography; + +/// +/// Compares byte sequences in an amount of time that does not depend on their contents. +/// +/// +/// Comparing an authentication tag with ==, SequenceEqual, or any comparison that +/// returns early on the first differing byte leaks where the difference is. An attacker who can +/// measure that can recover a valid tag one byte at a time. Prefer +/// , which computes and compares in one step; use this only +/// when the tag to compare against was produced elsewhere. +/// +/// The member is named rather than Equals because a static +/// class inherits . That overload accepts any two +/// arguments, so a call spelled FixedTimeComparison.Equals(a, b) would compile and silently +/// resolve to boxed reference equality for anything that does not implicitly convert to +/// , never reaching the fixed-time path. +/// +/// +public static class FixedTimeComparison +{ + /// + /// Determines whether two byte sequences are equal, in a time that does not vary with their contents. + /// + /// The first sequence. + /// The second sequence. + /// True if the sequences have the same length and contents, false otherwise. + public static bool FixedTimeEquals(ReadOnlySpan left, ReadOnlySpan right) + => CryptographicOperations.FixedTimeEquals(left, right); +} diff --git a/Essentials/IEncryptionProvider.cs b/Essentials/IEncryptionProvider.cs index 5bf070e..8c101fd 100644 --- a/Essentials/IEncryptionProvider.cs +++ b/Essentials/IEncryptionProvider.cs @@ -9,6 +9,20 @@ namespace ktsu.Essentials; /// /// Interface for encryption providers that can encrypt and decrypt data. /// +/// +/// Encryption providers give confidentiality only. Ciphertext produced through this interface is not +/// tamper-evident: nothing in the surface carries an authentication tag, so a modified ciphertext is +/// indistinguishable from an unmodified one and decryption of altered input succeeds or fails +/// depending only on whether the result happens to be well-formed. +/// +/// A caller who needs to detect tampering must authenticate the ciphertext separately, computing a +/// tag over the initialization vector and the ciphertext together with an +/// , then verifying that tag before decrypting. Covering only the +/// ciphertext is not enough. The initialization vector travels with it and feeds the first decrypted +/// block, so an attacker free to rewrite an unauthenticated one can change that block undetected. +/// Use a key for authentication that is separate from the encryption key. +/// +/// public interface IEncryptionProvider { /// diff --git a/Essentials/IIncrementalHash.cs b/Essentials/IIncrementalHash.cs index 748c900..2683606 100644 --- a/Essentials/IIncrementalHash.cs +++ b/Essentials/IIncrementalHash.cs @@ -9,8 +9,9 @@ namespace ktsu.Essentials; /// already moving for another reason instead of handing over a stream to be read. /// /// -/// Obtained from . Instances are stateful and are not -/// safe to share across threads. Dispose when finished. +/// Obtained from or +/// . Instances are stateful and +/// are not safe to share across threads. Dispose when finished. /// public interface IIncrementalHash : IDisposable { diff --git a/Essentials/IKeyedHashProvider.cs b/Essentials/IKeyedHashProvider.cs new file mode 100644 index 0000000..a140d3c --- /dev/null +++ b/Essentials/IKeyedHashProvider.cs @@ -0,0 +1,221 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Essentials; + +using System; +using System.Buffers; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +/// +/// Interface for keyed hash providers, which compute a message authentication code over data using +/// a secret key. +/// +/// +/// A keyed hash answers "was this produced by someone holding the key, and is it unmodified", which +/// an unkeyed cannot. provides +/// confidentiality but not integrity, so a caller who needs tamper detection over ciphertext +/// authenticates it with one of these. +/// +/// The key is passed per call rather than bound at construction, which matches +/// and keeps providers stateless singletons. A provider holding +/// key or algorithm state in a field is the defect recorded in the remarks on the SHA-256 provider, +/// where concurrent callers corrupted each other's in-progress hash. +/// +/// +/// Generate the key with a cryptographically secure random number generator, such as +/// , and make it at least long. An +/// empty or predictable key still produces a valid-looking tag, so nothing about the output signals +/// a weak key. Never reuse an encryption key for authentication. +/// +/// +public interface IKeyedHashProvider +{ + /// + /// The length of the authentication tag in bytes. + /// + public int HashLengthBytes { get; } + + /// + /// Tries to compute the authentication tag for the specified data. + /// + /// The secret key. + /// The data to authenticate. + /// The buffer to write the tag to. + /// The number of bytes written to . + /// True if the tag was written, false if the buffer was too small or the key rejected. + public bool TryHash(ReadOnlySpan key, ReadOnlySpan data, Span destination, out int bytesWritten); + + /// + /// Tries to compute the authentication tag for the data in the specified stream. + /// + /// The secret key. + /// The stream to authenticate. Read to its end from its current position. + /// The buffer to write the tag to. + /// The number of bytes written to . + /// True if the tag was written, false if the stream was null, the buffer too small, or the key rejected. + public bool TryHash(ReadOnlySpan key, Stream data, Span destination, out int bytesWritten); + + /// + /// Creates a keyed incremental hash that accepts data in successive chunks. + /// + /// + /// The default implementation accumulates every appended byte in memory and computes the tag in + /// one pass when it is requested. That is correct but it buffers the entire input, so implementers + /// should override this with a genuinely incremental implementation. Doing so also lets + /// + /// stream properly, because that method is built on this one. + /// + /// The secret key. + /// A new keyed incremental hash. The caller owns it and should dispose it, which zeroes the key copy. + public IIncrementalHash CreateIncremental(ReadOnlySpan key) => new BufferingKeyedIncrementalHash(this, key); + + /// + /// Asynchronously computes the authentication tag over a stream, reading it in one pass. + /// + /// + /// The key is rather than because a + /// span cannot cross an await boundary. The result is not reported through an out parameter + /// for the same reason; a return value of true guarantees exactly + /// bytes were written. + /// + /// The read buffer is scrubbed on its way back to the pool. .Shared is + /// process-wide, so without that the tail of the authenticated message stays readable to whatever + /// rents next. + /// + /// + /// The secret key. + /// The stream to authenticate. + /// The buffer to write the tag to. + /// The cancellation token. + /// True if the tag was written, false if the stream was null or the buffer too small. + public async Task TryHashAsync(ReadOnlyMemory key, Stream data, Memory destination, CancellationToken cancellationToken = default) + { + if (data is null || destination.Length < HashLengthBytes) + { + return false; + } + + using IIncrementalHash hash = CreateIncremental(key.Span); + byte[] buffer = ArrayPool.Shared.Rent(81920); + try + { + int read; + while ((read = await data.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + hash.Append(buffer.AsSpan(0, read)); + } + + return hash.TryGetHashAndReset(destination.Span, out int bytesWritten) + && bytesWritten == HashLengthBytes; + } + finally + { + ArrayPool.Shared.Return(buffer, clearArray: true); + } + } + + /// + /// Asynchronously computes the authentication tag over a stream, reading it in one pass. + /// + /// The secret key. + /// The stream to authenticate. + /// The cancellation token. + /// The authentication tag. + /// The tag could not be produced. + public async Task HashAsync(ReadOnlyMemory key, Stream data, CancellationToken cancellationToken = default) + { + byte[] hash = new byte[HashLengthBytes]; + return !await TryHashAsync(key, data, hash, cancellationToken).ConfigureAwait(false) + ? throw new InvalidOperationException($"Keyed hashing failed to produce {HashLengthBytes} bytes of output.") + : hash; + } + + /// + /// Asynchronously computes the authentication tag for the specified data. + /// + /// The secret key. + /// The data to authenticate. + /// The cancellation token. + /// The authentication tag. + public Task HashAsync(ReadOnlyMemory key, ReadOnlyMemory data, CancellationToken cancellationToken = default) + => ProviderHelpers.RunAsync(() => Hash(key.Span, data.Span), cancellationToken); + + /// + /// Computes the authentication tag for the specified data. + /// + /// The secret key. + /// The data to authenticate. + /// The authentication tag. + /// The tag could not be produced. + public byte[] Hash(ReadOnlySpan key, ReadOnlySpan data) + { + byte[] hash = new byte[HashLengthBytes]; + return !TryHash(key, data, hash, out int bytesWritten) || bytesWritten != HashLengthBytes + ? throw new InvalidOperationException($"Keyed hashing failed to produce {HashLengthBytes} bytes of output.") + : hash; + } + + /// + /// Computes the authentication tag for the UTF-8 encoding of the specified text. + /// + /// The secret key. + /// The text to authenticate. + /// The authentication tag. + public byte[] Hash(ReadOnlySpan key, string data) + { + byte[] bytes = Encoding.UTF8.GetBytes(data); + return Hash(key, bytes); + } + + /// + /// Computes the authentication tag over the data in the specified stream. + /// + /// The secret key. + /// The stream to authenticate. + /// The authentication tag. + /// The tag could not be produced. + public byte[] Hash(ReadOnlySpan key, Stream data) + { + byte[] hash = new byte[HashLengthBytes]; + return !TryHash(key, data, hash, out int bytesWritten) || bytesWritten != HashLengthBytes + ? throw new InvalidOperationException($"Keyed hashing failed to produce {HashLengthBytes} bytes of output.") + : hash; + } + + /// + /// Determines whether the supplied tag is the correct authentication tag for the data. + /// + /// + /// Prefer this to computing a tag and comparing it yourself. The comparison runs in a time that + /// does not depend on the tag's contents, so it does not leak how much of a forged tag was + /// correct. A tag of the wrong length is rejected without comparing. + /// + /// The secret key. + /// The data the tag is claimed to authenticate. + /// The tag to check. + /// True if the tag is correct for this key and data, false otherwise. + public bool Verify(ReadOnlySpan key, ReadOnlySpan data, ReadOnlySpan expected) + { + if (expected.Length != HashLengthBytes) + { + return false; + } + + byte[] actual = new byte[HashLengthBytes]; + try + { + return TryHash(key, data, actual, out int bytesWritten) + && bytesWritten == HashLengthBytes + && FixedTimeComparison.FixedTimeEquals(actual, expected); + } + finally + { + CryptographicOperations.ZeroMemory(actual); + } + } +} diff --git a/README.md b/README.md index c24158b..b84ec15 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,9 @@ - **Encoding**: `IEncodingProvider` with Base64 and Hex implementations for format/transport encoding - **Obfuscation**: `IObfuscationProvider` with XOR, Caesar, bit-rotation, byte-reversal, Base64, and Hex implementations, plus a `Composite` provider that pipelines several together. Obfuscation is reversible but is **not** encryption — it provides no confidentiality - **Dependency Injection**: every provider package ships an `AddProvider()` extension; `ktsu.Essentials.All` adds per-category helpers and a single `AddEssentials()`. Registrations are idempotent and expose each provider by both concrete type and interface -- **Encryption**: `IEncryptionProvider` with AES implementation including key and IV generation +- **Encryption**: `IEncryptionProvider` with AES implementation including key and IV generation. Provides confidentiality only, not tamper detection - **Hashing**: `IHashProvider` with 15 implementations (MD5, SHA1/256/384/512, FNV1/FNV1a 32/64-bit, CRC32/64, XxHash32/64/3/128) +- **Keyed Hashing**: `IKeyedHashProvider` with HMAC-SHA256/384/512 implementations for authenticating data, plus `Verify` for fixed-time tag checking - **Serialization**: `ISerializationProvider` with System.Text.Json, Newtonsoft.Json, YAML, and TOML implementations plus configurable `ISerializationOptions` - **Caching**: `ICacheProvider` with in-memory implementation supporting expiration and get-or-add semantics - **Persistence**: `IPersistenceProvider` with DataHome, ConfigHome, FileSystem, InMemory, and Temp implementations. `DataHome` and `ConfigHome` follow the XDG Base Directory layout on every platform — `$XDG_DATA_HOME` or `~/.local/share/` for application state, `$XDG_CONFIG_HOME` or `~/.config/` for user settings — with `~` resolving to `%USERPROFILE%` on Windows @@ -32,7 +33,7 @@ - **Filesystem**: `IFileSystemProvider` extending Testably.Abstractions for testable filesystem access - **Explicit Buffer Contract**: every span operation is `bool TryX(source, destination, out int bytesWritten)` and each category exposes a `GetMax…Length` bound, so callers can size a buffer up front and know exactly how much was written. Encoding, hashing and obfuscation run allocation-free on the span path; compression and encryption still buffer internally, because the underlying BCL APIs for those are stream-only - **Minimal Implementation Burden**: Default interface implementations reduce boilerplate — implement only the core `Try*` methods -- **Async Support**: Operations expose async variants with `CancellationToken` support. Stream hashing, and the stream paths of the compression and AES encryption providers, are genuinely asynchronous — they read and write with `ReadAsync`/`WriteAsync` and hold no thread. The encoding, obfuscation, serialization and in-memory variants are convenience wrappers that run synchronous work on the thread pool; span-destination operations have no async form, because an `out` parameter cannot cross an await boundary +- **Async Support**: Operations expose async variants with `CancellationToken` support. Stream hashing, keyed hash stream hashing, and the stream paths of the compression and AES encryption providers, are genuinely asynchronous — they read and write with `ReadAsync`/`WriteAsync` and hold no thread. The encoding, obfuscation, serialization and in-memory variants are convenience wrappers that run synchronous work on the thread pool; span-destination operations have no async form, because an `out` parameter cannot cross an await boundary - **Batteries-Included or Cherry-Pick**: Each provider ships as its own `ktsu.Essentials..` package; install the `ktsu.Essentials.All` meta-package to get every provider at once, or reference only the ones you need ## Installation @@ -178,6 +179,43 @@ string dataDir = UserDirectories.GetApplicationDataDirectory("MyApp"); string configDir = UserDirectories.GetApplicationConfigDirectory("MyApp"); ``` +### Keyed Hashing + +Pair `IKeyedHashProvider` with `IEncryptionProvider` to detect tampering, because encryption alone gives confidentiality but not integrity: + +```csharp +IEncryptionProvider encryption = provider.GetRequiredService(); +IKeyedHashProvider keyedHash = provider.GetRequiredService(); + +byte[] encryptionKey = encryption.GenerateKey(); +byte[] iv = encryption.GenerateIV(); +byte[] authenticationKey = System.Security.Cryptography.RandomNumberGenerator.GetBytes(keyedHash.HashLengthBytes); + +byte[] ciphertext = encryption.Encrypt("Hello, World!"u8, encryptionKey, iv); + +// Authenticate the IV together with the ciphertext. A tag over the ciphertext alone +// leaves the IV rewritable, and CBC recovers the first plaintext block from it. +byte[] authenticated = new byte[iv.Length + ciphertext.Length]; +iv.CopyTo(authenticated, 0); +ciphertext.CopyTo(authenticated, iv.Length); + +byte[] tag = keyedHash.Hash(authenticationKey, authenticated); + +// ...iv, ciphertext and tag travel together to the other side... +byte[] receivedTag = tag; + +// On the way back in, verify before decrypting. +if (!keyedHash.Verify(authenticationKey, authenticated, receivedTag)) +{ + throw new System.Security.Cryptography.CryptographicException("Ciphertext failed authentication."); +} + +byte[] plaintext = encryption.Decrypt(ciphertext, encryptionKey, iv); +``` + +For a large payload, authenticate incrementally with `CreateIncremental` and compare the result with +`FixedTimeComparison.FixedTimeEquals` instead of allocating a concatenated copy the way the example above does. + ### Implementing a Custom Provider Implementers only need to provide the core `Try*` methods — all other methods are inherited: @@ -249,7 +287,7 @@ Format/transport encoding (Base64, Hex) — not text character encodings. ### `IEncryptionProvider` -Encrypt and decrypt data with key and IV management. +Encrypt and decrypt data with key and IV management. Provides confidentiality only, not tamper detection. Pair with `IKeyedHashProvider` to authenticate the initialization vector and the ciphertext together before decrypting. | Name | Return Type | Description | | ---- | ----------- | ----------- | @@ -277,7 +315,7 @@ Hash data with configurable output length. Exposes `HashLengthBytes` property fo ### `IIncrementalHash` -A hash computation that accepts data in successive chunks. Obtained from `IHashProvider.CreateIncremental()`. Stateful, not thread-safe, and disposable. +A hash computation that accepts data in successive chunks. Obtained from `IHashProvider.CreateIncremental()` or `IKeyedHashProvider.CreateIncremental(key)`. Stateful, not thread-safe, and disposable. | Name | Return Type | Description | | ---- | ----------- | ----------- | @@ -286,6 +324,31 @@ A hash computation that accepts data in successive chunks. Obtained from `IHashP | `TryGetHashAndReset(Span, out int)` | `bool` | Write the hash and reset, reporting bytes written | | `GetHashAndReset()` | `byte[]` | Self-allocating variant of the above | +### `IKeyedHashProvider` + +Compute and verify authentication tags (MACs) over data using a secret key. Exposes `HashLengthBytes` property for the tag size in bytes. See [Keyed Hashing](#keyed-hashing) above for a usage example that pairs this with `IEncryptionProvider`. + +| Name | Return Type | Description | +| ---- | ----------- | ----------- | +| `TryHash(ReadOnlySpan, ReadOnlySpan, Span, out int)` | `bool` | Compute a tag, reporting bytes written | +| `TryHash(ReadOnlySpan, Stream, Span, out int)` | `bool` | Stream-based tag computation | +| `CreateIncremental(ReadOnlySpan)` | `IIncrementalHash` | Create a keyed incremental hash for chunk-by-chunk authentication | +| `TryHashAsync(ReadOnlyMemory, Stream, Memory, CancellationToken)` | `Task` | Genuinely async stream tag computation into a caller-owned buffer | +| `HashAsync(ReadOnlyMemory, Stream, CancellationToken)` | `Task` | Genuinely async self-allocating stream tag computation | +| `HashAsync(ReadOnlyMemory, ReadOnlyMemory, CancellationToken)` | `Task` | Async self-allocating tag computation | +| `Hash(ReadOnlySpan, ReadOnlySpan)` | `byte[]` | Self-allocating tag computation | +| `Hash(ReadOnlySpan, string)` | `byte[]` | Tag over a UTF8 string | +| `Hash(ReadOnlySpan, Stream)` | `byte[]` | Self-allocating stream-based tag computation | +| `Verify(ReadOnlySpan, ReadOnlySpan, ReadOnlySpan)` | `bool` | Fixed-time check of a tag against data | + +### `FixedTimeComparison` + +A static fixed-time byte comparison for a tag obtained outside `IKeyedHashProvider.Verify`, such as one computed incrementally with `CreateIncremental`. + +| Name | Return Type | Description | +| ---- | ----------- | ----------- | +| `FixedTimeEquals(ReadOnlySpan, ReadOnlySpan)` | `bool` | Compare two byte sequences in a time that does not depend on their contents | + ### `ISerializationProvider` Serialize and deserialize objects supporting JSON, YAML, TOML, and other text-based formats. diff --git a/Shared/HmacKeyedHashCore.cs b/Shared/HmacKeyedHashCore.cs new file mode 100644 index 0000000..b4930bf --- /dev/null +++ b/Shared/HmacKeyedHashCore.cs @@ -0,0 +1,156 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Essentials; + +using System; +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Security.Cryptography; + +/// +/// The HMAC implementation shared by the keyed hash providers, parameterized by algorithm. +/// +/// +/// Linked into each provider project rather than placed in the interfaces package, following +/// NonCryptoIncrementalHash. It is internal because every package compiles its own copy, so a +/// public type would collide for a consumer referencing more than one keyed hash package. +/// +/// Key material is copied because +/// takes an array on the floor target framework. Every copy is zeroed once the HMAC owns it. Placing +/// that here rather than in each provider means it is written once instead of three times. +/// +/// +internal static class HmacKeyedHashCore +{ + /// + /// Computes the authentication tag for a span of data. + /// + /// The hash algorithm underlying the HMAC. + /// The expected tag length. + /// The secret key. + /// The data to authenticate. + /// The buffer to write the tag to. + /// The number of bytes written. + /// True if the tag was written, false otherwise. + internal static bool TryHash(HashAlgorithmName algorithm, int hashLengthBytes, ReadOnlySpan key, ReadOnlySpan data, Span destination, out int bytesWritten) + { + bytesWritten = 0; + + if (destination.Length < hashLengthBytes) + { + return false; + } + + byte[] keyCopy = key.ToArray(); + try + { + using IncrementalHash hash = IncrementalHash.CreateHMAC(algorithm, keyCopy); + hash.AppendData(data); + if (!hash.TryGetHashAndReset(destination, out bytesWritten) || bytesWritten != hashLengthBytes) + { + bytesWritten = 0; + return false; + } + + return true; + } + catch (ArgumentException) + { + bytesWritten = 0; + return false; + } + catch (CryptographicException) + { + bytesWritten = 0; + return false; + } + finally + { + CryptographicOperations.ZeroMemory(keyCopy); + } + } + + /// + /// Computes the authentication tag over a stream, reading it in one pass. + /// + /// The hash algorithm underlying the HMAC. + /// The expected tag length. + /// The secret key. + /// The stream to authenticate. + /// The buffer to write the tag to. + /// The number of bytes written. + /// True if the tag was written, false otherwise. + internal static bool TryHash(HashAlgorithmName algorithm, int hashLengthBytes, ReadOnlySpan key, Stream data, Span destination, out int bytesWritten) + { + bytesWritten = 0; + + if (data is null || destination.Length < hashLengthBytes) + { + return false; + } + + byte[] keyCopy = key.ToArray(); + byte[] buffer = ArrayPool.Shared.Rent(81920); + try + { + using IncrementalHash hash = IncrementalHash.CreateHMAC(algorithm, keyCopy); + int read; + while ((read = data.Read(buffer, 0, buffer.Length)) > 0) + { + hash.AppendData(buffer.AsSpan(0, read)); + } + + if (!hash.TryGetHashAndReset(destination, out bytesWritten) || bytesWritten != hashLengthBytes) + { + bytesWritten = 0; + return false; + } + + return true; + } + catch (ArgumentException) + { + bytesWritten = 0; + return false; + } + catch (CryptographicException) + { + bytesWritten = 0; + return false; + } + catch (IOException) + { + bytesWritten = 0; + return false; + } + finally + { + ArrayPool.Shared.Return(buffer, clearArray: true); + CryptographicOperations.ZeroMemory(keyCopy); + } + } + + /// + /// Creates a genuinely incremental keyed hash. + /// + /// The hash algorithm underlying the HMAC. + /// The tag length. + /// The secret key. + /// An incremental hash the caller owns and should dispose. + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "Ownership of the IncrementalHash transfers to the returned IncrementalHashAdapter, which disposes it.")] + internal static IIncrementalHash CreateIncremental(HashAlgorithmName algorithm, int hashLengthBytes, ReadOnlySpan key) + { + byte[] keyCopy = key.ToArray(); + try + { + return new IncrementalHashAdapter( + IncrementalHash.CreateHMAC(algorithm, keyCopy), + hashLengthBytes); + } + finally + { + CryptographicOperations.ZeroMemory(keyCopy); + } + } +} diff --git a/TAGS.md b/TAGS.md index 3a97386..c7e9212 100644 --- a/TAGS.md +++ b/TAGS.md @@ -1 +1 @@ -.NET;C#;dotnet;csharp;essentials;interfaces;provider pattern;dependency injection;compression;gzip;brotli;deflate;zlib;encoding;base64;hex;obfuscation;xor;caesar;bit rotation;encryption;aes;hashing;md5;sha256;sha512;crc32;fnv;xxhash;serialization;json;newtonsoft;yaml;toml;caching;persistence;validation;logging;navigation;command execution;filesystem;zero-allocation;span;async;default interface implementations;meta-package +.NET;C#;dotnet;csharp;essentials;interfaces;provider pattern;dependency injection;compression;gzip;brotli;deflate;zlib;encoding;base64;hex;obfuscation;xor;caesar;bit rotation;encryption;aes;hashing;md5;sha256;sha512;crc32;fnv;xxhash;keyed hashing;hmac;mac;message authentication;serialization;json;newtonsoft;yaml;toml;caching;persistence;validation;logging;navigation;command execution;filesystem;zero-allocation;span;async;default interface implementations;meta-package diff --git a/docs/superpowers/plans/2026-08-25-keyed-hash-provider.md b/docs/superpowers/plans/2026-08-25-keyed-hash-provider.md new file mode 100644 index 0000000..20ddd27 --- /dev/null +++ b/docs/superpowers/plans/2026-08-25-keyed-hash-provider.md @@ -0,0 +1,1726 @@ +# Keyed Hash Provider Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `IKeyedHashProvider` and three HMAC provider packages so callers can compute and verify message authentication codes without leaving Essentials. + +**Architecture:** A new interface in the `Essentials` interfaces package with two required primitives and eight default bodies, mirroring `IHashProvider`. Three provider packages each delegate to one `internal` implementation linked from `Shared/`, so the providers do not duplicate against each other. Verification is a default method that computes and compares in fixed time, so callers never write a tag comparison themselves. + +**Tech Stack:** C#, .NET (net10.0 through net6.0 and netstandard2.1), ktsu.Sdk, MSTest, `System.Security.Cryptography.IncrementalHash`. + +**Spec:** `docs/superpowers/specs/2026-08-25-keyed-hash-delivery-design.md`, which defers to `docs/superpowers/specs/2026-08-19-keyed-hashing-and-incremental-hashing-design.md` for the interface design of record. + +## Global Constraints + +- **Target frameworks:** `net10.0;net9.0;net8.0;net7.0;net6.0;netstandard2.1` on every new project. +- **No conditional compilation.** Every API used is available on netstandard2.1. If you reach for `#if`, stop and reconsider. +- **Warnings are errors.** A build with any warning fails. +- **Tabs for indentation.** File-scoped namespaces. Using directives inside the namespace. +- **Line endings are LF, and you do not manage them.** `.gitattributes` line 11 is `* text=auto eol=lf`, which explicitly overrides each machine's `core.autocrlf` and checks files out as LF on every platform. The global CLAUDE.md says CRLF; for this repository that is stale and `.gitattributes` wins. Never convert line endings, and never treat an LF file here as a defect. +- **No `this.` qualifiers.** Name constructor parameters so they differ from fields. +- **Always brace control flow.** Always specify accessibility modifiers. +- **No global suppressions.** Use targeted `[SuppressMessage]` with a real justification. +- **File header:** every file starts with `// Copyright (c) 2023-2026 ktsu-dev contributors` followed by a blank line. +- **Preserve each file's existing byte order mark.** The repo is genuinely mixed and that is not a defect. +- **Commit tags:** `[minor]` on the commit that completes the feature, `[patch]` on the rest. Tag goes at the end of a lowercase conventional-commit subject. No `Co-Authored-By` lines. +- **Never stage `.gitignore`.** It shows as modified in `git status` but is not modified. Stage files explicitly by path, never `git add -A`. +- **All tests go in the existing `Essentials.Tests` project.** A second test project silently loses coverage, because KtsuBuild runs one solution-level `dotnet test --coverage` and every test project writes the same output file. +- **Default interface members are only callable through the interface type.** C# does not surface a default implementation on the implementing class, so `HmacSha256KeyedHashProvider p = new(); p.Hash(key, data);` does not compile — `Hash` is a default member. Type the local as `IKeyedHashProvider` instead. This applies to every member of `IKeyedHashProvider` except `HashLengthBytes`, the two `TryHash` primitives, and `CreateIncremental`, which the providers declare themselves. The existing `HashProviderTests` avoids this by receiving providers as `IHashProvider` from `[DynamicData]`. +- **`CA2007` is a build error, so every `await` in a test needs `.ConfigureAwait(false)`.** Precedent is in `AsyncStreamIoTests.cs` and `IncrementalHashTests.cs`. +- **Add `using System.Linq;` where the test code uses `Enumerable.Repeat` or `Count(...)`, and only there.** An unused using is a warning, and warnings are errors. +- **Check which overload a test actually binds to** before assuming it covers the method you changed. A `byte[]` argument converts to `ReadOnlySpan`, `ReadOnlyMemory`, and `Span` alike, so an intended test of the span path can silently bind elsewhere. This is how six rewritten public bodies nearly shipped untested during the async stream work. Where it matters, assert against a value only the intended overload can produce, or step through once to confirm. + +## File Structure + +**Interfaces package (`Essentials/`)** +- `IKeyedHashProvider.cs` — new. The contract: 2 required members, 8 defaults. +- `BufferingKeyedIncrementalHash.cs` — new, `internal sealed`. Backs the `CreateIncremental` default so third-party implementers need only write the two primitives. +- `FixedTimeComparison.cs` — new, public static. For callers holding a tag obtained elsewhere. +- `IEncryptionProvider.cs` — modify. Documentation only. + +**Shared (`Shared/`)** +- `HmacKeyedHashCore.cs` — new, `internal static`. Linked into all three provider projects. Must be `internal`: each package compiles its own copy, so a public type would collide for a consumer referencing two of them. + +**Provider packages** — three new projects, each with a `.csproj`, a provider class, and `ServiceCollectionExtensions.cs`: +- `Essentials.KeyedHashProviders.HmacSha256/` +- `Essentials.KeyedHashProviders.HmacSha384/` +- `Essentials.KeyedHashProviders.HmacSha512/` + +**Aggregation** +- `Essentials.All/Essentials.All.csproj` and `Essentials.All/ServiceCollectionExtensions.cs` — modify. +- `Essentials.slnx` — modify. + +**Tests** +- `Essentials.Tests/KeyedHashProviderTests.cs` — new. + +--- + +### Task 1: FixedTimeComparison + +The smallest independent unit, and nothing else depends on it existing first except `Verify` in Task 2. + +**Files:** +- Create: `Essentials/FixedTimeComparison.cs` +- Test: `Essentials.Tests/KeyedHashProviderTests.cs` + +**Interfaces:** +- Consumes: nothing. +- Produces: `public static bool FixedTimeComparison.Equals(ReadOnlySpan left, ReadOnlySpan right)` in namespace `ktsu.Essentials`. + +- [ ] **Step 1: Write the failing test** + +Create `Essentials.Tests/KeyedHashProviderTests.cs`: + +```csharp +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Essentials.Tests; + +using System; +using ktsu.Essentials; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[TestClass] +public class KeyedHashProviderTests +{ + #region FixedTimeComparison + + [TestMethod] + public void FixedTimeComparison_Matches_Identical_Spans() + { + byte[] left = [1, 2, 3, 4]; + byte[] right = [1, 2, 3, 4]; + + Assert.IsTrue(FixedTimeComparison.Equals(left, right)); + } + + [TestMethod] + public void FixedTimeComparison_Rejects_Single_Bit_Difference() + { + byte[] left = [1, 2, 3, 4]; + byte[] right = [1, 2, 3, 5]; + + Assert.IsFalse(FixedTimeComparison.Equals(left, right)); + } + + [TestMethod] + public void FixedTimeComparison_Rejects_Different_Lengths() + { + byte[] left = [1, 2, 3, 4]; + byte[] right = [1, 2, 3]; + + Assert.IsFalse(FixedTimeComparison.Equals(left, right)); + } + + [TestMethod] + public void FixedTimeComparison_Matches_Empty_Spans() + { + Assert.IsTrue(FixedTimeComparison.Equals([], [])); + } + + #endregion +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test Essentials.Tests/Essentials.Tests.csproj --filter "FullyQualifiedName~FixedTimeComparison"` + +Expected: compile failure, `FixedTimeComparison` does not exist. + +- [ ] **Step 3: Write the implementation** + +Create `Essentials/FixedTimeComparison.cs`: + +```csharp +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Essentials; + +using System; +using System.Security.Cryptography; + +/// +/// Compares byte sequences in an amount of time that does not depend on their contents. +/// +/// +/// Comparing an authentication tag with ==, SequenceEqual, or any comparison that +/// returns early on the first differing byte leaks where the difference is. An attacker who can +/// measure that can recover a valid tag one byte at a time. Prefer +/// , which computes and compares in one step; use this only +/// when the tag to compare against was produced elsewhere. +/// +public static class FixedTimeComparison +{ + /// + /// Determines whether two byte sequences are equal, in a time that does not vary with their contents. + /// + /// The first sequence. + /// The second sequence. + /// True if the sequences have the same length and contents, false otherwise. + public static bool Equals(ReadOnlySpan left, ReadOnlySpan right) + => CryptographicOperations.FixedTimeEquals(left, right); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test Essentials.Tests/Essentials.Tests.csproj --filter "FullyQualifiedName~FixedTimeComparison"` + +Expected: 4 passed. + +If an analyzer rejects the member name `Equals` on a static class (CA1716 or similar, which is an error here because warnings are errors), do not rename it. The spec of record names this member. Add a targeted `[SuppressMessage]` on the method with a justification saying the name is the established one for this operation. + +- [ ] **Step 5: Commit** + +```bash +git add Essentials/FixedTimeComparison.cs Essentials.Tests/KeyedHashProviderTests.cs +git commit -m "feat: add FixedTimeComparison for authentication tag comparison [patch]" +``` + +--- + +### Task 2: IKeyedHashProvider and its buffering default + +Delivers the contract. Tested through a minimal fake implementer that supplies only the two primitives, which is exactly what a third-party implementer would write, so this also proves the defaults work for them. + +**Files:** +- Create: `Essentials/IKeyedHashProvider.cs` +- Create: `Essentials/BufferingKeyedIncrementalHash.cs` +- Test: `Essentials.Tests/KeyedHashProviderTests.cs` + +**Interfaces:** +- Consumes: `FixedTimeComparison.Equals` from Task 1. `IIncrementalHash` and `ProviderHelpers.RunAsync` already exist. +- Produces: `IKeyedHashProvider` in namespace `ktsu.Essentials`, with members: + - `int HashLengthBytes { get; }` + - `bool TryHash(ReadOnlySpan key, ReadOnlySpan data, Span destination, out int bytesWritten)` + - `bool TryHash(ReadOnlySpan key, Stream data, Span destination, out int bytesWritten)` + - `IIncrementalHash CreateIncremental(ReadOnlySpan key)` + - `Task TryHashAsync(ReadOnlyMemory key, Stream data, Memory destination, CancellationToken cancellationToken = default)` + - `Task HashAsync(ReadOnlyMemory key, Stream data, CancellationToken cancellationToken = default)` + - `Task HashAsync(ReadOnlyMemory key, ReadOnlyMemory data, CancellationToken cancellationToken = default)` + - `byte[] Hash(ReadOnlySpan key, ReadOnlySpan data)` + - `byte[] Hash(ReadOnlySpan key, string data)` + - `byte[] Hash(ReadOnlySpan key, Stream data)` + - `bool Verify(ReadOnlySpan key, ReadOnlySpan data, ReadOnlySpan expected)` + +- [ ] **Step 1: Write the failing test** + +Add to `Essentials.Tests/KeyedHashProviderTests.cs`, inside the class, after the `FixedTimeComparison` region. Add `using System.IO;`, `using System.Linq;`, `using System.Text;`, and `using System.Threading.Tasks;` to the file's usings. + +```csharp + #region Default interface implementations + + /// + /// A minimal implementer supplying only the two required primitives, which is what a third-party + /// implementer writes. Exercising the defaults through this proves they do not secretly depend on + /// anything a real provider overrides. + /// + /// + /// The "MAC" is deliberately trivial and is not a real construction: each output byte is the + /// running sum of the data XORed with a key byte. It only needs to be deterministic, key-dependent, + /// and data-dependent for these tests to mean something. + /// + private sealed class FakeKeyedHashProvider : IKeyedHashProvider + { + public int HashLengthBytes => 8; + + public bool TryHash(ReadOnlySpan key, ReadOnlySpan data, Span destination, out int bytesWritten) + { + bytesWritten = 0; + + if (destination.Length < HashLengthBytes) + { + return false; + } + + for (int i = 0; i < HashLengthBytes; i++) + { + byte accumulator = key.Length > 0 ? key[i % key.Length] : (byte)0; + for (int j = 0; j < data.Length; j++) + { + accumulator = (byte)(accumulator + data[j] + i); + } + + destination[i] = accumulator; + } + + bytesWritten = HashLengthBytes; + return true; + } + + public bool TryHash(ReadOnlySpan key, Stream data, Span destination, out int bytesWritten) + { + bytesWritten = 0; + + if (data is null) + { + return false; + } + + using MemoryStream copy = new(); + data.CopyTo(copy); + return TryHash(key, copy.ToArray(), destination, out bytesWritten); + } + } + + private static readonly byte[] FakeKey = Encoding.UTF8.GetBytes("fake-key"); + private static readonly byte[] FakePayload = Encoding.UTF8.GetBytes("the quick brown fox"); + + [TestMethod] + public void Defaults_Hash_Span_Matches_TryHash() + { + IKeyedHashProvider provider = new FakeKeyedHashProvider(); + byte[] expected = new byte[provider.HashLengthBytes]; + Assert.IsTrue(provider.TryHash(FakeKey, FakePayload, expected, out int written)); + Assert.AreEqual(provider.HashLengthBytes, written); + + byte[] actual = provider.Hash(FakeKey, FakePayload); + + CollectionAssert.AreEqual(expected, actual); + } + + [TestMethod] + public void Defaults_Hash_Stream_Matches_Hash_Span() + { + IKeyedHashProvider provider = new FakeKeyedHashProvider(); + using MemoryStream stream = new(FakePayload); + + byte[] fromStream = provider.Hash(FakeKey, stream); + + CollectionAssert.AreEqual(provider.Hash(FakeKey, FakePayload), fromStream); + } + + [TestMethod] + public void Defaults_Hash_String_Matches_Utf8_Bytes() + { + IKeyedHashProvider provider = new FakeKeyedHashProvider(); + + byte[] fromString = provider.Hash(FakeKey, "the quick brown fox"); + + CollectionAssert.AreEqual(provider.Hash(FakeKey, FakePayload), fromString); + } + + [TestMethod] + public void Defaults_CreateIncremental_Matches_One_Shot() + { + IKeyedHashProvider provider = new FakeKeyedHashProvider(); + using IIncrementalHash incremental = provider.CreateIncremental(FakeKey); + incremental.Append(FakePayload.AsSpan(0, 5)); + incremental.Append(FakePayload.AsSpan(5)); + + byte[] actual = incremental.GetHashAndReset(); + + CollectionAssert.AreEqual(provider.Hash(FakeKey, FakePayload), actual); + } + + [TestMethod] + public async Task Defaults_TryHashAsync_Matches_One_Shot() + { + IKeyedHashProvider provider = new FakeKeyedHashProvider(); + using MemoryStream stream = new(FakePayload); + byte[] actual = new byte[provider.HashLengthBytes]; + + bool ok = await provider.TryHashAsync(FakeKey, stream, actual).ConfigureAwait(false); + + Assert.IsTrue(ok); + CollectionAssert.AreEqual(provider.Hash(FakeKey, FakePayload), actual); + } + + [TestMethod] + public async Task Defaults_HashAsync_Memory_Matches_One_Shot() + { + IKeyedHashProvider provider = new FakeKeyedHashProvider(); + + byte[] actual = await provider.HashAsync(FakeKey, FakePayload).ConfigureAwait(false); + + CollectionAssert.AreEqual(provider.Hash(FakeKey, FakePayload), actual); + } + + [TestMethod] + public async Task Defaults_HashAsync_Stream_Matches_One_Shot() + { + IKeyedHashProvider provider = new FakeKeyedHashProvider(); + using MemoryStream stream = new(FakePayload); + + byte[] actual = await provider.HashAsync(FakeKey, stream).ConfigureAwait(false); + + CollectionAssert.AreEqual(provider.Hash(FakeKey, FakePayload), actual); + } + + [TestMethod] + public void Defaults_TryHash_Rejects_Undersized_Destination() + { + IKeyedHashProvider provider = new FakeKeyedHashProvider(); + byte[] tooSmall = new byte[provider.HashLengthBytes - 1]; + + Assert.IsFalse(provider.TryHash(FakeKey, FakePayload, tooSmall, out int written)); + Assert.AreEqual(0, written); + } + + [TestMethod] + public void Defaults_Verify_Accepts_Correct_Tag() + { + IKeyedHashProvider provider = new FakeKeyedHashProvider(); + byte[] tag = provider.Hash(FakeKey, FakePayload); + + Assert.IsTrue(provider.Verify(FakeKey, FakePayload, tag)); + } + + [TestMethod] + public void Defaults_Verify_Rejects_Flipped_Tag_Bit() + { + IKeyedHashProvider provider = new FakeKeyedHashProvider(); + byte[] tag = provider.Hash(FakeKey, FakePayload); + tag[0] ^= 0x01; + + Assert.IsFalse(provider.Verify(FakeKey, FakePayload, tag)); + } + + [TestMethod] + public void Defaults_Verify_Rejects_Wrong_Key() + { + IKeyedHashProvider provider = new FakeKeyedHashProvider(); + byte[] tag = provider.Hash(FakeKey, FakePayload); + byte[] wrongKey = Encoding.UTF8.GetBytes("other-key"); + + Assert.IsFalse(provider.Verify(wrongKey, FakePayload, tag)); + } + + [TestMethod] + public void Defaults_Verify_Rejects_Truncated_Tag() + { + IKeyedHashProvider provider = new FakeKeyedHashProvider(); + byte[] tag = provider.Hash(FakeKey, FakePayload); + + Assert.IsFalse(provider.Verify(FakeKey, FakePayload, tag.AsSpan(0, tag.Length - 1))); + } + + #endregion +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test Essentials.Tests/Essentials.Tests.csproj --filter "FullyQualifiedName~KeyedHashProviderTests"` + +Expected: compile failure, `IKeyedHashProvider` does not exist. + +- [ ] **Step 3: Write the buffering incremental hash** + +Create `Essentials/BufferingKeyedIncrementalHash.cs`: + +```csharp +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Essentials; + +using System; +using System.IO; +using System.Security.Cryptography; + +/// +/// An that accumulates every appended byte and hashes the result in +/// one pass, for keyed hash providers that do not supply a genuinely incremental implementation. +/// +/// +/// Correct for any provider, but it holds the whole input in memory, which is the cost incremental +/// hashing exists to avoid. It backs the default body of +/// so that implementers need only write the two +/// required primitives; providers are expected to override it. The key is copied on construction and +/// zeroed on disposal, so the instance must be disposed. +/// +internal sealed class BufferingKeyedIncrementalHash : IIncrementalHash +{ + private readonly IKeyedHashProvider provider; + private readonly byte[] keyCopy; + private readonly MemoryStream buffer = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The provider whose one-shot stream hashing produces the digest. + /// The key, copied into this instance and zeroed on disposal. + internal BufferingKeyedIncrementalHash(IKeyedHashProvider keyedHashProvider, ReadOnlySpan key) + { + provider = keyedHashProvider; + keyCopy = key.ToArray(); + } + + /// + public int HashLengthBytes => provider.HashLengthBytes; + + /// + public void Append(ReadOnlySpan data) => buffer.Write(data); + + /// + public bool TryGetHashAndReset(Span destination, out int bytesWritten) + { + bytesWritten = 0; + + if (destination.Length < HashLengthBytes) + { + return false; + } + + buffer.Position = 0; + bool hashed = provider.TryHash(keyCopy, buffer, destination, out bytesWritten); + buffer.SetLength(0); + return hashed; + } + + /// + public void Dispose() + { + CryptographicOperations.ZeroMemory(keyCopy); + buffer.Dispose(); + } +} +``` + +- [ ] **Step 4: Write the interface** + +Create `Essentials/IKeyedHashProvider.cs`: + +```csharp +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Essentials; + +using System; +using System.Buffers; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +/// +/// Interface for keyed hash providers, which compute a message authentication code over data using +/// a secret key. +/// +/// +/// A keyed hash answers "was this produced by someone holding the key, and is it unmodified", which +/// an unkeyed cannot. provides +/// confidentiality but not integrity, so a caller who needs tamper detection over ciphertext +/// authenticates it with one of these. +/// +/// The key is passed per call rather than bound at construction, which matches +/// and keeps providers stateless singletons. A provider holding +/// key or algorithm state in a field is the defect recorded in the remarks on the SHA-256 provider, +/// where concurrent callers corrupted each other's in-progress hash. +/// +/// +public interface IKeyedHashProvider +{ + /// + /// The length of the authentication tag in bytes. + /// + public int HashLengthBytes { get; } + + /// + /// Tries to compute the authentication tag for the specified data. + /// + /// The secret key. + /// The data to authenticate. + /// The buffer to write the tag to. + /// The number of bytes written to . + /// True if the tag was written, false if the buffer was too small or the key rejected. + public bool TryHash(ReadOnlySpan key, ReadOnlySpan data, Span destination, out int bytesWritten); + + /// + /// Tries to compute the authentication tag for the data in the specified stream. + /// + /// The secret key. + /// The stream to authenticate. Read to its end from its current position. + /// The buffer to write the tag to. + /// The number of bytes written to . + /// True if the tag was written, false if the stream was null, the buffer too small, or the key rejected. + public bool TryHash(ReadOnlySpan key, Stream data, Span destination, out int bytesWritten); + + /// + /// Creates a keyed incremental hash that accepts data in successive chunks. + /// + /// + /// The default implementation accumulates every appended byte in memory and computes the tag in + /// one pass when it is requested. That is correct but it buffers the entire input, so implementers + /// should override this with a genuinely incremental implementation. Doing so also lets + /// + /// stream properly, because that method is built on this one. + /// + /// The secret key. + /// A new keyed incremental hash. The caller owns it and should dispose it, which zeroes the key copy. + public IIncrementalHash CreateIncremental(ReadOnlySpan key) => new BufferingKeyedIncrementalHash(this, key); + + /// + /// Asynchronously computes the authentication tag over a stream, reading it in one pass. + /// + /// + /// The key is rather than because a + /// span cannot cross an await boundary. The result is not reported through an out parameter + /// for the same reason; a return value of true guarantees exactly + /// bytes were written. + /// + /// The read buffer is scrubbed on its way back to the pool. .Shared is + /// process-wide, so without that the tail of the authenticated message stays readable to whatever + /// rents next. + /// + /// + /// The secret key. + /// The stream to authenticate. + /// The buffer to write the tag to. + /// The cancellation token. + /// True if the tag was written, false if the stream was null or the buffer too small. + public async Task TryHashAsync(ReadOnlyMemory key, Stream data, Memory destination, CancellationToken cancellationToken = default) + { + if (data is null || destination.Length < HashLengthBytes) + { + return false; + } + + using IIncrementalHash hash = CreateIncremental(key.Span); + byte[] buffer = ArrayPool.Shared.Rent(81920); + try + { + int read; + while ((read = await data.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + hash.Append(buffer.AsSpan(0, read)); + } + + return hash.TryGetHashAndReset(destination.Span, out int bytesWritten) + && bytesWritten == HashLengthBytes; + } + finally + { + ArrayPool.Shared.Return(buffer, clearArray: true); + } + } + + /// + /// Asynchronously computes the authentication tag over a stream, reading it in one pass. + /// + /// The secret key. + /// The stream to authenticate. + /// The cancellation token. + /// The authentication tag. + /// The tag could not be produced. + public async Task HashAsync(ReadOnlyMemory key, Stream data, CancellationToken cancellationToken = default) + { + byte[] hash = new byte[HashLengthBytes]; + return !await TryHashAsync(key, data, hash, cancellationToken).ConfigureAwait(false) + ? throw new InvalidOperationException($"Keyed hashing failed to produce {HashLengthBytes} bytes of output.") + : hash; + } + + /// + /// Asynchronously computes the authentication tag for the specified data. + /// + /// The secret key. + /// The data to authenticate. + /// The cancellation token. + /// The authentication tag. + public Task HashAsync(ReadOnlyMemory key, ReadOnlyMemory data, CancellationToken cancellationToken = default) + => ProviderHelpers.RunAsync(() => Hash(key.Span, data.Span), cancellationToken); + + /// + /// Computes the authentication tag for the specified data. + /// + /// The secret key. + /// The data to authenticate. + /// The authentication tag. + /// The tag could not be produced. + public byte[] Hash(ReadOnlySpan key, ReadOnlySpan data) + { + byte[] hash = new byte[HashLengthBytes]; + return !TryHash(key, data, hash, out int bytesWritten) || bytesWritten != HashLengthBytes + ? throw new InvalidOperationException($"Keyed hashing failed to produce {HashLengthBytes} bytes of output.") + : hash; + } + + /// + /// Computes the authentication tag for the UTF-8 encoding of the specified text. + /// + /// The secret key. + /// The text to authenticate. + /// The authentication tag. + public byte[] Hash(ReadOnlySpan key, string data) + { + byte[] bytes = Encoding.UTF8.GetBytes(data); + return Hash(key, bytes); + } + + /// + /// Computes the authentication tag over the data in the specified stream. + /// + /// The secret key. + /// The stream to authenticate. + /// The authentication tag. + /// The tag could not be produced. + public byte[] Hash(ReadOnlySpan key, Stream data) + { + byte[] hash = new byte[HashLengthBytes]; + return !TryHash(key, data, hash, out int bytesWritten) || bytesWritten != HashLengthBytes + ? throw new InvalidOperationException($"Keyed hashing failed to produce {HashLengthBytes} bytes of output.") + : hash; + } + + /// + /// Determines whether the supplied tag is the correct authentication tag for the data. + /// + /// + /// Prefer this to computing a tag and comparing it yourself. The comparison runs in a time that + /// does not depend on the tag's contents, so it does not leak how much of a forged tag was + /// correct. A tag of the wrong length is rejected without comparing. + /// + /// The secret key. + /// The data the tag is claimed to authenticate. + /// The tag to check. + /// True if the tag is correct for this key and data, false otherwise. + public bool Verify(ReadOnlySpan key, ReadOnlySpan data, ReadOnlySpan expected) + { + if (expected.Length != HashLengthBytes) + { + return false; + } + + byte[] actual = new byte[HashLengthBytes]; + try + { + return TryHash(key, data, actual, out int bytesWritten) + && bytesWritten == HashLengthBytes + && FixedTimeComparison.Equals(actual, expected); + } + finally + { + CryptographicOperations.ZeroMemory(actual); + } + } +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `dotnet test Essentials.Tests/Essentials.Tests.csproj --filter "FullyQualifiedName~KeyedHashProviderTests"` + +Expected: 16 passed (4 from Task 1, 12 here). + +- [ ] **Step 6: Verify every target framework builds** + +Run: `dotnet build Essentials/Essentials.csproj` + +Expected: `Build succeeded. 0 Warning(s) 0 Error(s)`. This is the check that no API used is missing on netstandard2.1. + +- [ ] **Step 7: Commit** + +```bash +git add Essentials/IKeyedHashProvider.cs Essentials/BufferingKeyedIncrementalHash.cs Essentials.Tests/KeyedHashProviderTests.cs +git commit -m "feat: add IKeyedHashProvider with buffering incremental default [patch]" +``` + +--- + +### Task 3: Shared HMAC core and the HmacSha256 package + +The first real provider. Proves the shared core works and pins the RFC vectors. + +**Files:** +- Create: `Shared/HmacKeyedHashCore.cs` +- Create: `Essentials.KeyedHashProviders.HmacSha256/Essentials.KeyedHashProviders.HmacSha256.csproj` +- Create: `Essentials.KeyedHashProviders.HmacSha256/HmacSha256KeyedHashProvider.cs` +- Create: `Essentials.KeyedHashProviders.HmacSha256/ServiceCollectionExtensions.cs` +- Modify: `Essentials.slnx` +- Test: `Essentials.Tests/KeyedHashProviderTests.cs` + +**Interfaces:** +- Consumes: `IKeyedHashProvider` from Task 2. `IncrementalHashAdapter(IncrementalHash inner, int hashLengthBytes)` already exists and is public. +- Produces: + - `internal static class HmacKeyedHashCore` with `TryHash(HashAlgorithmName, int, ReadOnlySpan, ReadOnlySpan, Span, out int)`, `TryHash(HashAlgorithmName, int, ReadOnlySpan, Stream, Span, out int)`, and `CreateIncremental(HashAlgorithmName, int, ReadOnlySpan)`. + - `public class HmacSha256KeyedHashProvider : IKeyedHashProvider` in `ktsu.Essentials.KeyedHashProviders.HmacSha256`, `HashLengthBytes` of 32. + - `public static IServiceCollection AddHmacSha256KeyedHashProvider(this IServiceCollection services)`. + +- [ ] **Step 1: Write the failing test** + +Add to `Essentials.Tests/KeyedHashProviderTests.cs`. Add `using ktsu.Essentials.KeyedHashProviders.HmacSha256;` to the usings. + +The vectors are RFC 4231 test cases 1, 2, and 6. Case 6 uses a 131-byte key, longer than the 64-byte block size, so the implementation must hash the key first. That path is invisible to round-trip testing and easy to get wrong. + +If one of these tests fails, suspect the vector before the implementation: check it against RFC 4231 section 4 rather than adjusting the code to match. + +```csharp + #region HMAC-SHA256 known answer vectors + + private static byte[] FromHex(string hex) + { + byte[] bytes = new byte[hex.Length / 2]; + for (int i = 0; i < bytes.Length; i++) + { + bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); + } + + return bytes; + } + + [TestMethod] + public void HmacSha256_Rfc4231_Case1() + { + IKeyedHashProvider provider = new HmacSha256KeyedHashProvider(); + byte[] key = Enumerable.Repeat((byte)0x0b, 20).ToArray(); + byte[] data = Encoding.UTF8.GetBytes("Hi There"); + + byte[] actual = provider.Hash(key, data); + + CollectionAssert.AreEqual( + FromHex("b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"), + actual); + } + + [TestMethod] + public void HmacSha256_Rfc4231_Case2() + { + IKeyedHashProvider provider = new HmacSha256KeyedHashProvider(); + byte[] key = Encoding.UTF8.GetBytes("Jefe"); + byte[] data = Encoding.UTF8.GetBytes("what do ya want for nothing?"); + + byte[] actual = provider.Hash(key, data); + + CollectionAssert.AreEqual( + FromHex("5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"), + actual); + } + + [TestMethod] + public void HmacSha256_Rfc4231_Case6_Oversized_Key() + { + IKeyedHashProvider provider = new HmacSha256KeyedHashProvider(); + byte[] key = Enumerable.Repeat((byte)0xaa, 131).ToArray(); + byte[] data = Encoding.UTF8.GetBytes("Test Using Larger Than Block-Size Key - Hash Key First"); + + byte[] actual = provider.Hash(key, data); + + CollectionAssert.AreEqual( + FromHex("60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"), + actual); + } + + [TestMethod] + public void HmacSha256_Agrees_With_Bcl() + { + IKeyedHashProvider provider = new HmacSha256KeyedHashProvider(); + byte[] key = Encoding.UTF8.GetBytes("a key of some length"); + byte[] data = Encoding.UTF8.GetBytes("a payload to authenticate"); + + byte[] actual = provider.Hash(key, data); + + using HMACSHA256 reference = new(key); + CollectionAssert.AreEqual(reference.ComputeHash(data), actual); + } + + [TestMethod] + public void HmacSha256_All_Four_Paths_Agree() + { + IKeyedHashProvider provider = new HmacSha256KeyedHashProvider(); + byte[] key = Encoding.UTF8.GetBytes("agreement key"); + byte[] data = Encoding.UTF8.GetBytes("a payload long enough to span several appends"); + byte[] oneShot = provider.Hash(key, data); + + using MemoryStream stream = new(data); + byte[] fromStream = provider.Hash(key, stream); + + using IIncrementalHash incremental = provider.CreateIncremental(key); + incremental.Append(data.AsSpan(0, 7)); + incremental.Append(data.AsSpan(7, 20)); + incremental.Append(data.AsSpan(27)); + byte[] fromIncremental = incremental.GetHashAndReset(); + + CollectionAssert.AreEqual(oneShot, fromStream); + CollectionAssert.AreEqual(oneShot, fromIncremental); + } + + [TestMethod] + public async Task HmacSha256_Async_Agrees_With_One_Shot() + { + IKeyedHashProvider provider = new HmacSha256KeyedHashProvider(); + byte[] key = Encoding.UTF8.GetBytes("async key"); + byte[] data = Encoding.UTF8.GetBytes("a payload to authenticate asynchronously"); + using MemoryStream stream = new(data); + + byte[] fromAsync = await provider.HashAsync(key, stream).ConfigureAwait(false); + + CollectionAssert.AreEqual(provider.Hash(key, data), fromAsync); + } + + [TestMethod] + public void HmacSha256_Reports_Exact_Length_And_Leaves_Tail_Untouched() + { + IKeyedHashProvider provider = new HmacSha256KeyedHashProvider(); + byte[] key = Encoding.UTF8.GetBytes("contract key"); + byte[] data = Encoding.UTF8.GetBytes("contract payload"); + byte[] buffer = new byte[provider.HashLengthBytes + 16]; + buffer.AsSpan().Fill(0xCD); + + Assert.IsTrue(provider.TryHash(key, data, buffer, out int written)); + + Assert.AreEqual(provider.HashLengthBytes, written); + foreach (byte b in buffer.AsSpan(written).ToArray()) + { + Assert.AreEqual(0xCD, b, "the tail of the caller's buffer must not be touched"); + } + } + + #endregion +``` + +Add `using System.Security.Cryptography;` to the file's usings for the BCL comparison test. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test Essentials.Tests/Essentials.Tests.csproj --filter "FullyQualifiedName~HmacSha256"` + +Expected: compile failure, `HmacSha256KeyedHashProvider` does not exist. + +- [ ] **Step 3: Write the shared core** + +Create `Shared/HmacKeyedHashCore.cs`: + +```csharp +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Essentials; + +using System; +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Security.Cryptography; + +/// +/// The HMAC implementation shared by the keyed hash providers, parameterized by algorithm. +/// +/// +/// Linked into each provider project rather than placed in the interfaces package, following +/// NonCryptoIncrementalHash. It is internal because every package compiles its own copy, so a +/// public type would collide for a consumer referencing more than one keyed hash package. +/// +/// Key material is copied because +/// takes an array on the floor target framework. Every copy is zeroed once the HMAC owns it. Placing +/// that here rather than in each provider means it is written once instead of three times. +/// +/// +internal static class HmacKeyedHashCore +{ + /// + /// Computes the authentication tag for a span of data. + /// + /// The hash algorithm underlying the HMAC. + /// The expected tag length. + /// The secret key. + /// The data to authenticate. + /// The buffer to write the tag to. + /// The number of bytes written. + /// True if the tag was written, false otherwise. + internal static bool TryHash(HashAlgorithmName algorithm, int hashLengthBytes, ReadOnlySpan key, ReadOnlySpan data, Span destination, out int bytesWritten) + { + bytesWritten = 0; + + if (destination.Length < hashLengthBytes) + { + return false; + } + + byte[] keyCopy = key.ToArray(); + try + { + using IncrementalHash hash = IncrementalHash.CreateHMAC(algorithm, keyCopy); + hash.AppendData(data); + if (!hash.TryGetHashAndReset(destination, out bytesWritten) || bytesWritten != hashLengthBytes) + { + bytesWritten = 0; + return false; + } + + return true; + } + catch (ArgumentException) + { + bytesWritten = 0; + return false; + } + catch (CryptographicException) + { + bytesWritten = 0; + return false; + } + finally + { + CryptographicOperations.ZeroMemory(keyCopy); + } + } + + /// + /// Computes the authentication tag over a stream, reading it in one pass. + /// + /// The hash algorithm underlying the HMAC. + /// The expected tag length. + /// The secret key. + /// The stream to authenticate. + /// The buffer to write the tag to. + /// The number of bytes written. + /// True if the tag was written, false otherwise. + internal static bool TryHash(HashAlgorithmName algorithm, int hashLengthBytes, ReadOnlySpan key, Stream data, Span destination, out int bytesWritten) + { + bytesWritten = 0; + + if (data is null || destination.Length < hashLengthBytes) + { + return false; + } + + byte[] keyCopy = key.ToArray(); + byte[] buffer = ArrayPool.Shared.Rent(81920); + try + { + using IncrementalHash hash = IncrementalHash.CreateHMAC(algorithm, keyCopy); + int read; + while ((read = data.Read(buffer, 0, buffer.Length)) > 0) + { + hash.AppendData(buffer.AsSpan(0, read)); + } + + if (!hash.TryGetHashAndReset(destination, out bytesWritten) || bytesWritten != hashLengthBytes) + { + bytesWritten = 0; + return false; + } + + return true; + } + catch (ArgumentException) + { + bytesWritten = 0; + return false; + } + catch (CryptographicException) + { + bytesWritten = 0; + return false; + } + catch (IOException) + { + bytesWritten = 0; + return false; + } + finally + { + ArrayPool.Shared.Return(buffer, clearArray: true); + CryptographicOperations.ZeroMemory(keyCopy); + } + } + + /// + /// Creates a genuinely incremental keyed hash. + /// + /// The hash algorithm underlying the HMAC. + /// The tag length. + /// The secret key. + /// An incremental hash the caller owns and should dispose. + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "Ownership of the IncrementalHash transfers to the returned IncrementalHashAdapter, which disposes it.")] + internal static IIncrementalHash CreateIncremental(HashAlgorithmName algorithm, int hashLengthBytes, ReadOnlySpan key) + { + byte[] keyCopy = key.ToArray(); + try + { + return new IncrementalHashAdapter( + IncrementalHash.CreateHMAC(algorithm, keyCopy), + hashLengthBytes); + } + finally + { + CryptographicOperations.ZeroMemory(keyCopy); + } + } +} +``` + +- [ ] **Step 4: Create the project file** + +Create `Essentials.KeyedHashProviders.HmacSha256/Essentials.KeyedHashProviders.HmacSha256.csproj`: + +```xml + + + + + net10.0;net9.0;net8.0;net7.0;net6.0;netstandard2.1 + true + + + + + + + + + + + + + + + + + +``` + +- [ ] **Step 5: Write the provider** + +Create `Essentials.KeyedHashProviders.HmacSha256/HmacSha256KeyedHashProvider.cs`: + +```csharp +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Essentials.KeyedHashProviders.HmacSha256; + +using System; +using System.IO; +using System.Security.Cryptography; +using ktsu.Essentials; + +/// +/// A keyed hash provider that uses HMAC-SHA-256 to authenticate data. +/// +/// +/// This type is stateless and safe to share across threads, because the key is supplied per call +/// rather than held in a field. Every operation delegates to the shared HMAC core, which owns key +/// copying and zeroing. +/// +public class HmacSha256KeyedHashProvider : IKeyedHashProvider +{ + /// + /// The length of the HMAC-SHA-256 tag in bytes (32 bytes / 256 bits). + /// + public int HashLengthBytes => 32; + + /// + public bool TryHash(ReadOnlySpan key, ReadOnlySpan data, Span destination, out int bytesWritten) + => HmacKeyedHashCore.TryHash(HashAlgorithmName.SHA256, HashLengthBytes, key, data, destination, out bytesWritten); + + /// + public bool TryHash(ReadOnlySpan key, Stream data, Span destination, out int bytesWritten) + => HmacKeyedHashCore.TryHash(HashAlgorithmName.SHA256, HashLengthBytes, key, data, destination, out bytesWritten); + + /// + public IIncrementalHash CreateIncremental(ReadOnlySpan key) + => HmacKeyedHashCore.CreateIncremental(HashAlgorithmName.SHA256, HashLengthBytes, key); +} +``` + +- [ ] **Step 6: Write the DI registration** + +Create `Essentials.KeyedHashProviders.HmacSha256/ServiceCollectionExtensions.cs`: + +```csharp +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Essentials.KeyedHashProviders.HmacSha256; + +using ktsu.Essentials; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +/// +/// Dependency injection registration for the HMAC-SHA-256 keyed hashing provider. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers the HMAC-SHA-256 keyed hashing provider. + /// + /// + /// The provider is registered as a singleton, both as its concrete type and as an additional + /// in the resolvable set, so it can be resolved either way. The + /// container constructs and owns each registration. Calling this more than once is a no-op. + /// + /// The service collection to add the provider to. + /// The same service collection, to allow chaining. + public static IServiceCollection AddHmacSha256KeyedHashProvider(this IServiceCollection services) + { + Ensure.NotNull(services); + + services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + return services; + } +} +``` + +- [ ] **Step 7: Add the project to the solution and the test project** + +In `Essentials.slnx`, add alongside the other provider entries, keeping alphabetical order: + +```xml + +``` + +In `Essentials.Tests/Essentials.Tests.csproj`, add a `ProjectReference` to the new project, matching how the other provider projects are referenced there. + +- [ ] **Step 8: Run the tests to verify they pass** + +Run: `dotnet test Essentials.Tests/Essentials.Tests.csproj --filter "FullyQualifiedName~KeyedHashProviderTests"` + +Expected: 23 passed. + +If `HmacSha256_Rfc4231_Case6_Oversized_Key` is the only failure, the key-hashing path is wrong. If cases 1, 2, and 6 all fail but `HmacSha256_Agrees_With_Bcl` passes, the vectors were mis-transcribed, so check RFC 4231 section 4. + +- [ ] **Step 9: Verify every target framework builds** + +Run: `dotnet build Essentials.KeyedHashProviders.HmacSha256/Essentials.KeyedHashProviders.HmacSha256.csproj` + +Expected: `Build succeeded. 0 Warning(s) 0 Error(s)`. + +- [ ] **Step 10: Commit** + +```bash +git add Shared/HmacKeyedHashCore.cs Essentials.KeyedHashProviders.HmacSha256 Essentials.slnx Essentials.Tests/Essentials.Tests.csproj Essentials.Tests/KeyedHashProviderTests.cs +git commit -m "feat: add HMAC-SHA-256 keyed hash provider over a shared core [patch]" +``` + +--- + +### Task 4: HmacSha384 and HmacSha512 packages + +Proves the shared core generalizes. Both providers are created together because neither is interesting alone and the review question is the same for both. + +**Files:** +- Create: `Essentials.KeyedHashProviders.HmacSha384/` (3 files, mirroring Task 3) +- Create: `Essentials.KeyedHashProviders.HmacSha512/` (3 files, mirroring Task 3) +- Modify: `Essentials.slnx`, `Essentials.Tests/Essentials.Tests.csproj` +- Test: `Essentials.Tests/KeyedHashProviderTests.cs` + +**Interfaces:** +- Consumes: `HmacKeyedHashCore` and `IKeyedHashProvider` from Task 3. +- Produces: `HmacSha384KeyedHashProvider` (`HashLengthBytes` 48) and `HmacSha512KeyedHashProvider` (`HashLengthBytes` 64), plus `AddHmacSha384KeyedHashProvider` and `AddHmacSha512KeyedHashProvider`. + +- [ ] **Step 1: Write the failing tests** + +Add to `Essentials.Tests/KeyedHashProviderTests.cs`, with usings for both new namespaces: + +```csharp + #region HMAC-SHA384 and HMAC-SHA512 known answer vectors + + [TestMethod] + public void HmacSha384_Rfc4231_Case1() + { + IKeyedHashProvider provider = new HmacSha384KeyedHashProvider(); + byte[] key = Enumerable.Repeat((byte)0x0b, 20).ToArray(); + + byte[] actual = provider.Hash(key, Encoding.UTF8.GetBytes("Hi There")); + + CollectionAssert.AreEqual( + FromHex("afd03944d84895626b0825f4ab46907f15f9dadbe4101ec682aa034c7cebc59cfaea9ea9076ede7f4af152e8b2fa9cb6"), + actual); + } + + [TestMethod] + public void HmacSha384_Rfc4231_Case2() + { + IKeyedHashProvider provider = new HmacSha384KeyedHashProvider(); + byte[] key = Encoding.UTF8.GetBytes("Jefe"); + + byte[] actual = provider.Hash(key, Encoding.UTF8.GetBytes("what do ya want for nothing?")); + + CollectionAssert.AreEqual( + FromHex("af45d2e376484031617f78d2b58a6b1b9c7ef464f5a01b47e42ec3736322445e8e2240ca5e69e2c78b3239ecfab21649"), + actual); + } + + [TestMethod] + public void HmacSha384_Rfc4231_Case6_Oversized_Key() + { + IKeyedHashProvider provider = new HmacSha384KeyedHashProvider(); + byte[] key = Enumerable.Repeat((byte)0xaa, 131).ToArray(); + + byte[] actual = provider.Hash(key, Encoding.UTF8.GetBytes("Test Using Larger Than Block-Size Key - Hash Key First")); + + CollectionAssert.AreEqual( + FromHex("4ece084485813e9088d2c63a041bc5b44f9ef1012a2b588f3cd11f05033ac4c60c2ef6ab4030fe8296248df163f44952"), + actual); + } + + [TestMethod] + public void HmacSha512_Rfc4231_Case1() + { + IKeyedHashProvider provider = new HmacSha512KeyedHashProvider(); + byte[] key = Enumerable.Repeat((byte)0x0b, 20).ToArray(); + + byte[] actual = provider.Hash(key, Encoding.UTF8.GetBytes("Hi There")); + + CollectionAssert.AreEqual( + FromHex("87aa7cdea5ef619d4ff0b4241a1d6cb02379f4e2ce4ec2787ad0b30545e17cdedaa833b7d6b8a702038b274eaea3f4e4be9d914eeb61f1702e696c203a126854"), + actual); + } + + [TestMethod] + public void HmacSha512_Rfc4231_Case2() + { + IKeyedHashProvider provider = new HmacSha512KeyedHashProvider(); + byte[] key = Encoding.UTF8.GetBytes("Jefe"); + + byte[] actual = provider.Hash(key, Encoding.UTF8.GetBytes("what do ya want for nothing?")); + + CollectionAssert.AreEqual( + FromHex("164b7a7bfcf819e2e395fbe73b56e0a387bd64222e831fd610270cd7ea2505549758bf75c05a994a6d034f65f8f0e6fdcaeab1a34d4a6b4b636e070a38bce737"), + actual); + } + + [TestMethod] + public void HmacSha512_Rfc4231_Case6_Oversized_Key() + { + IKeyedHashProvider provider = new HmacSha512KeyedHashProvider(); + byte[] key = Enumerable.Repeat((byte)0xaa, 131).ToArray(); + + byte[] actual = provider.Hash(key, Encoding.UTF8.GetBytes("Test Using Larger Than Block-Size Key - Hash Key First")); + + CollectionAssert.AreEqual( + FromHex("80b24263c7c1a3ebb71493c1dd7be8b49b46d1f41b4aeec1121b013783f8f3526b56d037e05f2598bd0fd2215d6a1e5295e64f73f63f0aec8b915a985d786598"), + actual); + } + + [TestMethod] + public void HmacSha384_And_512_Report_Their_Tag_Lengths() + { + Assert.AreEqual(48, new HmacSha384KeyedHashProvider().HashLengthBytes); + Assert.AreEqual(64, new HmacSha512KeyedHashProvider().HashLengthBytes); + } + + #endregion +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test Essentials.Tests/Essentials.Tests.csproj --filter "FullyQualifiedName~HmacSha384|FullyQualifiedName~HmacSha512"` + +Expected: compile failure, the provider types do not exist. + +- [ ] **Step 3: Write the HmacSha384 provider** + +Create `Essentials.KeyedHashProviders.HmacSha384/HmacSha384KeyedHashProvider.cs`: + +```csharp +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Essentials.KeyedHashProviders.HmacSha384; + +using System; +using System.IO; +using System.Security.Cryptography; +using ktsu.Essentials; + +/// +/// A keyed hash provider that uses HMAC-SHA-384 to authenticate data. +/// +/// +/// This type is stateless and safe to share across threads, because the key is supplied per call +/// rather than held in a field. Every operation delegates to the shared HMAC core, which owns key +/// copying and zeroing. +/// +public class HmacSha384KeyedHashProvider : IKeyedHashProvider +{ + /// + /// The length of the HMAC-SHA-384 tag in bytes (48 bytes / 384 bits). + /// + public int HashLengthBytes => 48; + + /// + public bool TryHash(ReadOnlySpan key, ReadOnlySpan data, Span destination, out int bytesWritten) + => HmacKeyedHashCore.TryHash(HashAlgorithmName.SHA384, HashLengthBytes, key, data, destination, out bytesWritten); + + /// + public bool TryHash(ReadOnlySpan key, Stream data, Span destination, out int bytesWritten) + => HmacKeyedHashCore.TryHash(HashAlgorithmName.SHA384, HashLengthBytes, key, data, destination, out bytesWritten); + + /// + public IIncrementalHash CreateIncremental(ReadOnlySpan key) + => HmacKeyedHashCore.CreateIncremental(HashAlgorithmName.SHA384, HashLengthBytes, key); +} +``` + +Create `Essentials.KeyedHashProviders.HmacSha384/ServiceCollectionExtensions.cs`: + +```csharp +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Essentials.KeyedHashProviders.HmacSha384; + +using ktsu.Essentials; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +/// +/// Dependency injection registration for the HMAC-SHA-384 keyed hashing provider. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers the HMAC-SHA-384 keyed hashing provider. + /// + /// + /// The provider is registered as a singleton, both as its concrete type and as an additional + /// in the resolvable set, so it can be resolved either way. The + /// container constructs and owns each registration. Calling this more than once is a no-op. + /// + /// The service collection to add the provider to. + /// The same service collection, to allow chaining. + public static IServiceCollection AddHmacSha384KeyedHashProvider(this IServiceCollection services) + { + Ensure.NotNull(services); + + services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + return services; + } +} +``` + +- [ ] **Step 4: Write the HmacSha512 provider** + +Create `Essentials.KeyedHashProviders.HmacSha512/HmacSha512KeyedHashProvider.cs`: + +```csharp +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Essentials.KeyedHashProviders.HmacSha512; + +using System; +using System.IO; +using System.Security.Cryptography; +using ktsu.Essentials; + +/// +/// A keyed hash provider that uses HMAC-SHA-512 to authenticate data. +/// +/// +/// This type is stateless and safe to share across threads, because the key is supplied per call +/// rather than held in a field. Every operation delegates to the shared HMAC core, which owns key +/// copying and zeroing. +/// +public class HmacSha512KeyedHashProvider : IKeyedHashProvider +{ + /// + /// The length of the HMAC-SHA-512 tag in bytes (64 bytes / 512 bits). + /// + public int HashLengthBytes => 64; + + /// + public bool TryHash(ReadOnlySpan key, ReadOnlySpan data, Span destination, out int bytesWritten) + => HmacKeyedHashCore.TryHash(HashAlgorithmName.SHA512, HashLengthBytes, key, data, destination, out bytesWritten); + + /// + public bool TryHash(ReadOnlySpan key, Stream data, Span destination, out int bytesWritten) + => HmacKeyedHashCore.TryHash(HashAlgorithmName.SHA512, HashLengthBytes, key, data, destination, out bytesWritten); + + /// + public IIncrementalHash CreateIncremental(ReadOnlySpan key) + => HmacKeyedHashCore.CreateIncremental(HashAlgorithmName.SHA512, HashLengthBytes, key); +} +``` + +Create `Essentials.KeyedHashProviders.HmacSha512/ServiceCollectionExtensions.cs` as the HmacSha384 one above, replacing `384` with `512` throughout, in the namespace, the class names, the method name `AddHmacSha512KeyedHashProvider`, and the prose `HMAC-SHA-512`. + +Create both `.csproj` files as byte-identical copies of `Essentials.KeyedHashProviders.HmacSha256/Essentials.KeyedHashProviders.HmacSha256.csproj`. That file names no algorithm and no project anywhere in its contents, so nothing inside it changes. Only the file name and directory differ. Keep the `` item in both, since that link is what gives each package its own copy of the shared core. + +- [ ] **Step 5: Add both projects to the solution and the test project** + +Add to `Essentials.slnx` in alphabetical order: + +```xml + + +``` + +Add matching `ProjectReference` entries to `Essentials.Tests/Essentials.Tests.csproj`. + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `dotnet test Essentials.Tests/Essentials.Tests.csproj --filter "FullyQualifiedName~KeyedHashProviderTests"` + +Expected: 30 passed. + +- [ ] **Step 7: Commit** + +```bash +git add Essentials.KeyedHashProviders.HmacSha384 Essentials.KeyedHashProviders.HmacSha512 Essentials.slnx Essentials.Tests/Essentials.Tests.csproj Essentials.Tests/KeyedHashProviderTests.cs +git commit -m "feat: add HMAC-SHA-384 and HMAC-SHA-512 keyed hash providers [patch]" +``` + +--- + +### Task 5: Essentials.All wiring + +Makes the providers reachable from the meta-package and `AddEssentials()`. + +**Files:** +- Modify: `Essentials.All/Essentials.All.csproj` +- Modify: `Essentials.All/ServiceCollectionExtensions.cs` +- Test: `Essentials.Tests/KeyedHashProviderTests.cs` + +**Interfaces:** +- Consumes: the three `Add…KeyedHashProvider` methods from Tasks 3 and 4. +- Produces: `public static IServiceCollection AddKeyedHashProviders(this IServiceCollection services)`, called from `AddEssentials()`. + +- [ ] **Step 1: Write the failing test** + +Add to `Essentials.Tests/KeyedHashProviderTests.cs`, with `using ktsu.Essentials.All;` and `using Microsoft.Extensions.DependencyInjection;`: + +```csharp + #region Dependency injection + + [TestMethod] + public void AddKeyedHashProviders_Registers_All_Three() + { + ServiceCollection services = new(); + services.AddKeyedHashProviders(); + using ServiceProvider provider = services.BuildServiceProvider(); + + IKeyedHashProvider[] providers = [.. provider.GetServices()]; + + Assert.AreEqual(3, providers.Length); + Assert.AreEqual(1, providers.Count(p => p.HashLengthBytes == 32)); + Assert.AreEqual(1, providers.Count(p => p.HashLengthBytes == 48)); + Assert.AreEqual(1, providers.Count(p => p.HashLengthBytes == 64)); + } + + [TestMethod] + public void AddKeyedHashProviders_Resolves_Concrete_Types() + { + ServiceCollection services = new(); + services.AddKeyedHashProviders(); + using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.IsNotNull(provider.GetService()); + Assert.IsNotNull(provider.GetService()); + Assert.IsNotNull(provider.GetService()); + } + + [TestMethod] + public void AddEssentials_Includes_Keyed_Hash_Providers() + { + ServiceCollection services = new(); + services.AddEssentials(); + using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.AreEqual(3, provider.GetServices().Count()); + } + + #endregion +``` + +Add `using System.Linq;` if it is not already present. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test Essentials.Tests/Essentials.Tests.csproj --filter "FullyQualifiedName~AddKeyedHashProviders|FullyQualifiedName~AddEssentials_Includes"` + +Expected: compile failure, `AddKeyedHashProviders` does not exist. + +- [ ] **Step 3: Add the project references** + +In `Essentials.All/Essentials.All.csproj`, add in alphabetical position among the existing `ProjectReference` items: + +```xml + + + +``` + +- [ ] **Step 4: Add the registration method** + +In `Essentials.All/ServiceCollectionExtensions.cs`, add the three namespaces to the usings, then add this method immediately after `AddHashProviders`, matching the surrounding style: + +```csharp + /// + /// Registers every bundled keyed hash provider. + /// + /// The service collection to add the providers to. + /// The same service collection, to allow chaining. + public static IServiceCollection AddKeyedHashProviders(this IServiceCollection services) + { + Ensure.NotNull(services); + + return services + .AddHmacSha256KeyedHashProvider() + .AddHmacSha384KeyedHashProvider() + .AddHmacSha512KeyedHashProvider(); + } +``` + +In the `AddEssentials` chain, add `.AddKeyedHashProviders()` immediately after `.AddHashProviders()` to keep the chain alphabetical. + +- [ ] **Step 5: Run the full test suite** + +Run: `dotnet test Essentials.Tests/Essentials.Tests.csproj` + +Expected: all tests pass, including the pre-existing `DiTests`. If a `DiTests` assertion counts registered providers, update the expected count and note it in the commit message. + +- [ ] **Step 6: Commit** + +```bash +git add Essentials.All Essentials.Tests/KeyedHashProviderTests.cs +git commit -m "feat: register keyed hash providers in Essentials.All [patch]" +``` + +--- + +### Task 6: Document the encryption guarantee + +Documentation only. No code in either type changes. This is the half of issue #5 that is about the expectation gap rather than the missing algorithm. + +**Files:** +- Modify: `Essentials/IEncryptionProvider.cs` +- Modify: `Essentials.EncryptionProviders.Aes/AesEncryptionProvider.cs` + +**Interfaces:** +- Consumes: `IKeyedHashProvider` exists, so the remarks can point at it. +- Produces: nothing. + +- [ ] **Step 1: Add remarks to the interface** + +In `Essentials/IEncryptionProvider.cs`, replace the existing `` block above `public interface IEncryptionProvider` with: + +```csharp +/// +/// Interface for encryption providers that can encrypt and decrypt data. +/// +/// +/// Encryption providers give confidentiality only. Ciphertext produced through this interface is not +/// tamper-evident: nothing in the surface carries an authentication tag, so a modified ciphertext is +/// indistinguishable from an unmodified one and decryption of altered input succeeds or fails +/// depending only on whether the result happens to be well-formed. +/// +/// A caller who needs to detect tampering must authenticate the ciphertext separately, computing a +/// tag over the initialization vector and the ciphertext together with an +/// , then verifying that tag before decrypting. Covering only the +/// ciphertext is not enough. The initialization vector travels with it and feeds the first decrypted +/// block, so an attacker free to rewrite an unauthenticated one can change that block undetected. +/// Use a key for authentication that is separate from the encryption key. +/// +/// +``` + +Do not reference an authenticated encryption interface. That type does not exist yet, and pointing at it is worse than not pointing at all. It returns when the AEAD work lands. + +- [ ] **Step 2: Add remarks to the AES provider** + +In `Essentials.EncryptionProviders.Aes/AesEncryptionProvider.cs`, extend the existing `` on the class (which currently covers thread safety) by appending these paragraphs inside the same block: + +```csharp +/// +/// This provider is AES in CBC mode with PKCS7 padding, which is what Aes.Create() defaults to. +/// CBC ciphertext is malleable: an attacker who can modify it can make predictable changes to the +/// decrypted plaintext without knowing the key. Decryption reports padding failures, so a caller who +/// decrypts attacker-supplied input and reveals whether it parsed becomes a padding oracle. +/// +/// +/// Authenticate the initialization vector and the ciphertext together before decrypting them, if they +/// crossed a boundary you do not control. CBC recovers the first plaintext block as the initialization +/// vector XORed with the decryption of the first ciphertext block, so a tag covering only the +/// ciphertext still leaves that block rewritable. See the remarks on +/// . +/// +``` + +Note that the existing remarks block on this type contains an em dash. Leave it alone. It is pre-existing and not part of this change. + +- [ ] **Step 3: Verify the build** + +Run: `dotnet build Essentials/Essentials.csproj && dotnet build Essentials.EncryptionProviders.Aes/Essentials.EncryptionProviders.Aes.csproj` + +Expected: `0 Warning(s) 0 Error(s)` for both. Malformed doc XML fails the build here, so this step is the test. + +- [ ] **Step 4: Commit** + +```bash +git add Essentials/IEncryptionProvider.cs Essentials.EncryptionProviders.Aes/AesEncryptionProvider.cs +git commit -m "docs: state that encryption providers give confidentiality only [patch]" +``` + +--- + +### Task 7: README and CLAUDE.md + +The last task, carrying the `[minor]` tag that releases the feature. + +**Files:** +- Modify: `README.md` +- Modify: `CLAUDE.md` + +**Interfaces:** +- Consumes: everything from Tasks 1 to 6. +- Produces: nothing. + +- [ ] **Step 1: Update README.md** + +Three edits: + +1. In the feature list, reword the encryption bullet to say confidentiality only, and add a keyed hashing bullet after the hashing bullet: + +```markdown +- **Keyed Hashing**: `IKeyedHashProvider` with HMAC-SHA256/384/512 implementations for authenticating data, plus `Verify` for fixed-time tag checking +``` + +2. In the Provider Implementations list, add a `KeyedHashProviders` entry naming the three packages, matching the shape of the `HashProviders` entry. + +3. In the API Reference, add an `IKeyedHashProvider` section listing the two required members and the nine defaults, and add a confidentiality-only note to the `IEncryptionProvider` section. + +Include a usage example showing the pairing the issue asks for: + +```csharp +// Authenticate ciphertext that crossed a boundary you do not control. +byte[] tag = keyedHash.Hash(authenticationKey, ciphertext); + +// On the way back in, verify before decrypting. +if (!keyedHash.Verify(authenticationKey, ciphertext, receivedTag)) +{ + return false; +} +``` + +- [ ] **Step 2: Update CLAUDE.md** + +- Add `Essentials/IKeyedHashProvider.cs` and `Essentials/FixedTimeComparison.cs` to the Key Files list, each with a one-line description matching the style of the surrounding entries. +- Add `Shared/HmacKeyedHashCore.cs`, describing it as linked into the three keyed hash provider projects rather than placed in the interfaces package. +- Add a **KeyedHashProviders** line to the Provider Implementations list: `HmacSha256, HmacSha384, HmacSha512`. +- Add `KeyedHashProviderTests.cs` to the Testing list. + +- [ ] **Step 3: Verify the whole solution builds and every test passes** + +Run: `dotnet build Essentials.slnx` then `dotnet test Essentials.Tests/Essentials.Tests.csproj` + +Expected: `Build succeeded. 0 Warning(s) 0 Error(s)` and every test passing. The full solution build takes roughly 10 minutes because of the target framework matrix. + +- [ ] **Step 4: Commit** + +```bash +git add README.md CLAUDE.md +git commit -m "feat: add keyed hash providers for message authentication [minor]" +``` + +- [ ] **Step 5: Refresh the generated metadata** + +Run the `update-docs` skill to refresh `DESCRIPTION.md` and `TAGS.md` rather than hand-editing them. Commit whatever it changes with a `[patch]` tag. + +Never hand-edit `VERSION.md`, `CHANGELOG.md`, or `LICENSE.md`. + +- [ ] **Step 6: Open the pull request** + +```bash +git push -u origin feat/keyed-hash-provider +``` + +The pull request body should lead with what a caller can now do that they could not before, name the three packages, and state that the change is purely additive so no major version is needed. Link issue #5. + +--- + +## After the plan + +File a follow-up issue for `IAuthenticatedEncryptionProvider` and AES-GCM, carrying across the design already written in Part 2 of `docs/superpowers/specs/2026-08-19-keyed-hashing-and-incremental-hashing-design.md` so that thinking is not lost. Label it `P3`. diff --git a/docs/superpowers/specs/2026-08-19-keyed-hashing-and-incremental-hashing-design.md b/docs/superpowers/specs/2026-08-19-keyed-hashing-and-incremental-hashing-design.md index eebcb38..ba2c65a 100644 --- a/docs/superpowers/specs/2026-08-19-keyed-hashing-and-incremental-hashing-design.md +++ b/docs/superpowers/specs/2026-08-19-keyed-hashing-and-incremental-hashing-design.md @@ -3,6 +3,13 @@ Design for GitHub issues [#6](https://github.com/ktsu-dev/Essentials/issues/6) and [#5](https://github.com/ktsu-dev/Essentials/issues/5). +> **Status, as of 2026-08-25.** Part 1 shipped as PR #9 and closed issue #6. The interface designs +> below remain the design of record and are unchanged. The **Delivery** section is superseded: +> issue #5 no longer ships alongside authenticated encryption, which moves to its own issue and pull +> request. See +> [`2026-08-25-keyed-hash-delivery-design.md`](2026-08-25-keyed-hash-delivery-design.md) for the +> split, and for how the three HMAC providers share an implementation. + ## Problem Both issues come from [ktsu-dev/GitLfsCache](https://github.com/ktsu-dev/GitLfsCache), which had to diff --git a/docs/superpowers/specs/2026-08-25-keyed-hash-delivery-design.md b/docs/superpowers/specs/2026-08-25-keyed-hash-delivery-design.md new file mode 100644 index 0000000..378e421 --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-keyed-hash-delivery-design.md @@ -0,0 +1,196 @@ +# Keyed hashing: delivering issue #5 without the AEAD half + +## Status + +This is a delivery spec, not a replacement design. The interface design of record is +[`2026-08-19-keyed-hashing-and-incremental-hashing-design.md`](2026-08-19-keyed-hashing-and-incremental-hashing-design.md), +whose Part 2 covers issue #5. Every decision in that document stands. This spec records three things +that document does not settle: + +1. The delivery is split, so authenticated encryption no longer ships alongside keyed hashing. +2. How the three HMAC providers share an implementation, which the earlier spec left open. +3. What changed on `main` since 2026-08-19 that makes point 2 matter. + +Part 1 of the earlier spec shipped as PR #9 and closed issue #6. Part 2 has not shipped. Part 3, +documentation, is split across both deliveries and the remainder travels with this one. + +## Scope + +### In scope + +- `Essentials/IKeyedHashProvider.cs`, exactly the surface specified in the earlier spec. +- `Essentials/FixedTimeComparison.cs`, forwarding to `CryptographicOperations.FixedTimeEquals`. +- Three packages: `ktsu.Essentials.KeyedHashProviders.HmacSha256`, `.HmacSha384`, and `.HmacSha512`. +- `Shared/HmacKeyedHashCore.cs`, linked into all three. +- Documentation of the confidentiality-only guarantee on `IEncryptionProvider` and + `AesEncryptionProvider`. +- Tests, `Essentials.All` wiring, and the README and CLAUDE.md updates for this surface. + +### Deliberately deferred + +`IAuthenticatedEncryptionProvider` and the AES-GCM package move to their own issue and their own +pull request. The earlier spec placed them in the same delivery as keyed hashing, and that pairing +is worth breaking. They share no code, and AEAD carries a design question that keyed hashing does +not: whether an interface that returns a tag can express every AEAD mode a caller might want, or +only the one the first implementation happens to use. Shipping keyed hashing first also unblocks the +consumer named in the issue, which needs a MAC and not an AEAD. + +The reference to `IAuthenticatedEncryptionProvider` in the new `IEncryptionProvider` remarks is +dropped from this delivery, because pointing at a type that does not exist yet is worse than not +pointing at all. It returns when the AEAD work lands. + +HMAC-MD5 and HMAC-SHA1 remain unshipped, for the reason the earlier spec gives: they are not broken +as MACs, but placing them in a new security-facing category invites misuse, and a consumer who needs +one implements the two primitives themselves. + +## What changed since 2026-08-19 + +`main` currently fails its SonarCloud quality gate on `new_duplicated_lines_density`, at 6.8% against +a 3% threshold, from 504 duplicated lines. The largest contributor is the FNV hash provider cluster +at 214 lines, four files of 168 lines each that differ in two lines of logic and their doc comments. +The compression providers contribute roughly 192 more. + +That is the same shape this work would produce. Three HMAC providers written independently differ +only in an algorithm name and a hash length, so they would duplicate against each other at +much the same rate and push a gate that is already red further from green. + +The earlier spec says new projects "follow the existing scaffolding convention". Followed literally, +for three providers this near-identical, that convention is the defect. + +## Implementation approach: one shared core + +`Shared/HmacKeyedHashCore.cs`, linked into all three provider projects, following the precedent of +`Shared/NonCryptoIncrementalHash.cs`, which is `internal sealed` in namespace `ktsu.Essentials` and +already linked into six provider projects: + +```xml + +``` + +`internal` is required rather than stylistic. Each package compiles its own copy, so a public type +would collide across packages for any consumer referencing more than one. + +The core carries the algorithm-independent work, parameterized by `HashAlgorithmName` and hash +length: + +```csharp +internal static class HmacKeyedHashCore +{ + internal static bool TryHash(HashAlgorithmName algorithm, int hashLengthBytes, + ReadOnlySpan key, ReadOnlySpan data, Span destination, out int bytesWritten); + + internal static bool TryHash(HashAlgorithmName algorithm, int hashLengthBytes, + ReadOnlySpan key, Stream data, Span destination, out int bytesWritten); + + internal static IIncrementalHash CreateIncremental(HashAlgorithmName algorithm, + int hashLengthBytes, ReadOnlySpan key); +} +``` + +Each provider reduces to its distinct content: + +```csharp +public class HmacSha256KeyedHashProvider : IKeyedHashProvider +{ + public int HashLengthBytes => 32; + + /// + public bool TryHash(ReadOnlySpan key, ReadOnlySpan data, Span destination, out int bytesWritten) + => HmacKeyedHashCore.TryHash(HashAlgorithmName.SHA256, HashLengthBytes, key, data, destination, out bytesWritten); + + // the Stream primitive and CreateIncremental follow the same one-line shape +} +``` + +Roughly 25 lines of distinct content per provider instead of 170, and no cross-provider duplication +for Sonar to find. + +`CreateIncremental` is overridden rather than inherited. The buffering default specified in the +earlier spec exists for third-party implementers, and silently inheriting it is the trap that spec +already names for `IHashProvider`. `IncrementalHash.CreateHMAC` makes the override a single line. + +## Platform constraints + +Carried forward from the earlier spec, which verified these by compiling probe projects against each +target framework rather than assuming them. Re-confirmed against the netstandard2.1 reference +assembly for this work: + +Available on netstandard2.1 and every later target: + +- `IncrementalHash.CreateHMAC(HashAlgorithmName, byte[])` +- `CryptographicOperations.FixedTimeEquals` and `CryptographicOperations.ZeroMemory` +- `HMACSHA256` and friends, including `TryComputeHash(ReadOnlySpan, Span, out int)` + +Not available on netstandard2.1: + +- `IncrementalHash.HashLengthInBytes`. Immaterial, because every provider knows its own length as a + constant. + +No conditional compilation is expected in this delivery. The earlier spec's single permitted `#if` +was for AES-GCM construction, which has moved out of scope. + +One implementation-time check remains, and it is an optimization rather than a correctness +question: whether a span-accepting `CreateHMAC` overload exists on the newer targets. The `byte[]` +overload is available everywhere and is the fallback, so the work proceeds either way. Confirm it by +building all six target frameworks rather than by reading documentation. + +### Key material + +`IncrementalHash.CreateHMAC` takes `byte[]` on the floor target, so the key is copied there. Every +copy is zeroed with `CryptographicOperations.ZeroMemory` once the HMAC is constructed, and every +`IncrementalHash` is disposed. This is the reason the core owns key handling rather than each +provider repeating it, and getting it wrong in one of three places is exactly the failure the shared +core prevents. + +The pooled read buffer in the async stream path is returned with `clearArray: true`, consistent with +the fix in issue #12. `ArrayPool.Shared` is process-wide, so the tail of an authenticated +message would otherwise stay readable to whatever rents next. + +## Testing + +All tests live in the existing `Essentials.Tests` project. A second test project would silently lose +coverage, because KtsuBuild runs one solution-level `dotnet test --coverage` and every test project +writes the same output file. + +- **RFC 4231 known-answer vectors** for HMAC-SHA256, HMAC-SHA384, and HMAC-SHA512. These prove + interoperability with every other implementation, which round-trip tests cannot. They include the + oversized-key case, where a key longer than the block size is hashed first, and that path is easy + to get wrong and invisible to self-consistency testing. +- **Agreement across all four paths.** One-shot, stream, incremental, and async must produce + identical output for identical input, with the incremental case driven at several chunk boundaries. +- **`Verify` fails closed.** True for a correct tag, false for a tag with any single bit flipped, + false for a correct tag under the wrong key, and false for a truncated tag. +- **Undersized destination buffers return false** rather than throwing, matching the contract every + other provider category follows. +- **The buffer length contract**, mirroring `ProviderContractTests`: report the exact bytes written + and leave the rest of the caller's buffer untouched. +- **DI registration**, resolving each provider both concretely and through `IKeyedHashProvider`. + +Note for whoever writes these: check which overload the tests actually bind to. Pre-existing async +tests in this repo bound to the `ReadOnlyMemory`/`string` overloads, which is how six rewritten +public bodies nearly shipped untested during the async stream work. + +## Documentation + +`IEncryptionProvider` gains `` stating that it provides confidentiality only, that +ciphertext is not tamper-evident, and that a caller needing integrity must authenticate the +ciphertext separately. `AesEncryptionProvider` gains a note that it is CBC with PKCS7, that its +ciphertext is malleable, and that a decrypt-then-parse caller becomes a padding oracle. This mirrors +the warning `IObfuscationProvider` already carries in its summary. No code in either type changes. + +`README.md` gains a keyed hashing bullet and an API Reference section, and its encryption bullet is +reworded to confidentiality-only. `CLAUDE.md` gains the new provider category, the key files, and +the new test file. `DESCRIPTION.md` and `TAGS.md` go through the `update-docs` skill rather than +being hand-edited. + +The README overclaim named in issue #6, on line 35, was addressed in the Part 1 delivery and is not +revisited here. + +## Delivery + +One pull request tagged `[minor]`, carrying the interface, the helper, three packages, the shared +core, the `Essentials.All` wiring, the documentation, and the tests. Purely additive, so no major +version. + +A follow-up issue is filed for `IAuthenticatedEncryptionProvider` and AES-GCM, carrying the design +already written in Part 2 of the earlier spec so none of that thinking is lost.