diff --git a/CHANGELOG.md b/CHANGELOG.md index a520a8c..6177c46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/) and this * `PerformanceBuilder.EnableQueryCaching()` — compose the existing `WithCaching(c => c.UseMemoryCache())` configuration with your query path; QuerySpec does not own a query-result cache. * `PerformanceBuilder.OptimizeExpressions()` — no replacement; expression compilation is already handled by EF Core / `IQueryable` providers. * `SecurityBuilder.RotateKeysEvery(int)` — implement key rotation at the storage layer following the same pattern as the `RotateKeyAsync` removal in [#138](https://github.com/AbongileBoja/QuerySpec/issues/138). Closes [#139](https://github.com/AbongileBoja/QuerySpec/issues/139). +* **security:** `IEncryptionProvider.RotateKeyAsync()` and `IEncryptionProvider.RotateKeyAsync(CancellationToken)` removed. Both overloads were `[Obsolete(error: true)]` in 3.x and every shipping implementation (`AesEncryptionProvider`, `AesGcmEncryptionProvider`, `MigratingEncryptionProvider`) threw `NotSupportedException`. ApiCompat reports `CP0001` (member removed) on both. Implement key rotation at the storage layer instead: construct a new provider with the new key, decrypt under the old provider, re-encrypt under the new provider, persist, then cut over reads. For `MigratingEncryptionProvider` specifically, the existing prefix-tag dispatch already supports this pattern — register the previous writer as a legacy reader and promote the new writer. Closes [#138](https://github.com/AbongileBoja/QuerySpec/issues/138). ### Added diff --git a/src/QuerySpec.Core/Security/AesEncryptionProvider.cs b/src/QuerySpec.Core/Security/AesEncryptionProvider.cs index 5e81649..59acbfa 100644 --- a/src/QuerySpec.Core/Security/AesEncryptionProvider.cs +++ b/src/QuerySpec.Core/Security/AesEncryptionProvider.cs @@ -110,20 +110,6 @@ public string Decrypt(string ciphertext) return Encoding.UTF8.GetString(plainBytes); } - /// - /// Key rotation requires re-encrypting every ciphertext under the new key. This provider - /// does not own the persisted ciphertexts. The method previously returned silently which - /// gave callers a false belief that rotation had happened. Deprecated and will be removed - /// in 3.0; implement rotation at the storage layer. - /// - /// Always thrown. - [Obsolete("Key rotation is a storage-layer concern; this method will be removed in 3.0. Implement rotation in the storage layer (Azure Key Vault, AWS KMS, etc.) rather than on IEncryptionProvider.", error: true)] - public Task RotateKeyAsync() => - throw new NotSupportedException( - "AesEncryptionProvider does not own the persisted ciphertexts and cannot rotate. " + - "Construct a new provider with the new key, decrypt with the old, re-encrypt with the new at the storage layer. " + - "Prefer AesGcmEncryptionProvider for new ciphertexts."); - /// Generates a new 256-bit encryption key. /// A base64-encoded 32-byte AES key suitable for the constructor. public static string GenerateKey() diff --git a/src/QuerySpec.Core/Security/AesGcmEncryptionProvider.cs b/src/QuerySpec.Core/Security/AesGcmEncryptionProvider.cs index f84216a..7ad9723 100644 --- a/src/QuerySpec.Core/Security/AesGcmEncryptionProvider.cs +++ b/src/QuerySpec.Core/Security/AesGcmEncryptionProvider.cs @@ -102,20 +102,6 @@ public string Decrypt(string ciphertext, ReadOnlySpan associatedData) return Encoding.UTF8.GetString(plain); } - /// - /// Key rotation requires re-encrypting every ciphertext under the new key. This provider - /// holds a single immutable key and does not own the persisted ciphertexts; callers must - /// implement rotation at their storage layer (decrypt under old key, re-encrypt under new - /// key). The method is implemented as a throwing stub so callers cannot mistake a no-op - /// for a real rotation. Deprecated and will be removed in 3.0. - /// - /// Always thrown. - [Obsolete("Key rotation is a storage-layer concern; this method will be removed in 3.0. Implement rotation in the storage layer (Azure Key Vault, AWS KMS, etc.) rather than on IEncryptionProvider.", error: true)] - public Task RotateKeyAsync() => - throw new NotSupportedException( - "AesGcmEncryptionProvider does not own the persisted ciphertexts and therefore cannot rotate. " + - "Construct a new instance with the new key, decrypt with the old, re-encrypt with the new at the storage layer."); - /// Generates a new 256-bit AES key, base64-encoded. public static string GenerateKey() { diff --git a/src/QuerySpec.Core/Security/IEncryptionProvider.cs b/src/QuerySpec.Core/Security/IEncryptionProvider.cs index a26ce4c..05b8c14 100644 --- a/src/QuerySpec.Core/Security/IEncryptionProvider.cs +++ b/src/QuerySpec.Core/Security/IEncryptionProvider.cs @@ -1,7 +1,3 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - namespace QuerySpec.Core.Security; /// @@ -17,25 +13,4 @@ public interface IEncryptionProvider /// Provider-defined ciphertext envelope as produced by . /// The recovered UTF-8 plaintext. string Decrypt(string ciphertext); - - /// - /// Rotates the encryption key. Deprecated: every shipping implementation throws - /// because the provider does not own the persisted - /// ciphertexts. Implement rotation at the storage layer (Azure Key Vault, AWS KMS, etc.): - /// decrypt under the old provider, re-encrypt under the new provider. - /// - /// A task that completes once the rotation has been performed by the implementation. - [Obsolete("Key rotation is a storage-layer concern; this method will be removed in 3.0. Implement rotation in the storage layer (Azure Key Vault, AWS KMS, etc.) rather than on IEncryptionProvider.", error: true)] - Task RotateKeyAsync(); - /// - /// Cancellation-aware overload of . Deprecated alongside the - /// no-token overload; will be removed in 3.0. Implement key rotation at the storage layer. - /// - /// Token observed by implementations that perform I/O during rotation. - /// A task that completes once the rotation has been performed by the implementation. - [Obsolete("Key rotation is a storage-layer concern; this method will be removed in 3.0. Implement rotation in the storage layer (Azure Key Vault, AWS KMS, etc.) rather than on IEncryptionProvider.", error: true)] - Task RotateKeyAsync(CancellationToken cancellationToken) -#pragma warning disable CS0619 // Obsolete-error self-reference: the DIM delegates to the CT-less overload that is itself obsolete; both ship as a coupled deprecation pair removed together in 3.0. - => RotateKeyAsync(); -#pragma warning restore CS0619 } diff --git a/src/QuerySpec.Core/Security/MigratingEncryptionProvider.cs b/src/QuerySpec.Core/Security/MigratingEncryptionProvider.cs index a4f033d..19b7033 100644 --- a/src/QuerySpec.Core/Security/MigratingEncryptionProvider.cs +++ b/src/QuerySpec.Core/Security/MigratingEncryptionProvider.cs @@ -128,20 +128,6 @@ public string Decrypt(string ciphertext) /// The tag stamped on every ciphertext written by this instance. public string WriteTag { get; } - /// - /// Migration is meaningful only at the storage layer (decrypt under reader, re-encrypt - /// under writer). Calling this on the wrapper is almost always a mistake — it would - /// rotate the inner writer's key without re-encrypting any persisted ciphertexts and - /// would invalidate the readers for tags pointing at the same physical provider. - /// Deprecated and will be removed in 3.0. - /// - /// Always thrown. - [Obsolete("Key rotation is a storage-layer concern; this method will be removed in 3.0. Implement rotation in the storage layer (Azure Key Vault, AWS KMS, etc.) rather than on IEncryptionProvider.", error: true)] - public Task RotateKeyAsync() => - throw new NotSupportedException( - "MigratingEncryptionProvider does not own the inner providers' keys and cannot rotate. " + - "Construct a new instance with the new writer (and the previous writer demoted to a reader) and re-encrypt at the storage layer."); - internal static void ValidateTag(string tag, string paramName) { ArgumentException.ThrowIfNullOrWhiteSpace(tag, paramName); diff --git a/tests/QuerySpec.Core.Tests/Security/AesGcmEncryptionProviderTests.cs b/tests/QuerySpec.Core.Tests/Security/AesGcmEncryptionProviderTests.cs index a479a67..0b52d45 100644 --- a/tests/QuerySpec.Core.Tests/Security/AesGcmEncryptionProviderTests.cs +++ b/tests/QuerySpec.Core.Tests/Security/AesGcmEncryptionProviderTests.cs @@ -155,16 +155,6 @@ public void EnvelopeLength_Equals_NoncePlusTagPlusUtf8PlaintextLength() Assert.Equal(12 + 16 + utf8Len, bytes.Length); } - [Fact] - public void RotateKeyAsync_Throws_NotSupported() - { - var p = new AesGcmEncryptionProvider(NewKey()); - - var method = typeof(AesGcmEncryptionProvider).GetMethod(nameof(IEncryptionProvider.RotateKeyAsync), Type.EmptyTypes)!; - var ex = Assert.Throws(() => method.Invoke(p, null)); - Assert.IsType(ex.InnerException); - } - [Fact] public void GenerateKey_Produces_Base64_32Bytes() { diff --git a/tests/QuerySpec.Core.Tests/Security/MigratingEncryptionProviderTests.cs b/tests/QuerySpec.Core.Tests/Security/MigratingEncryptionProviderTests.cs index 9e155b8..177745c 100644 --- a/tests/QuerySpec.Core.Tests/Security/MigratingEncryptionProviderTests.cs +++ b/tests/QuerySpec.Core.Tests/Security/MigratingEncryptionProviderTests.cs @@ -142,16 +142,6 @@ public void Aead_Constructor_NullWriter_Throws() new MigratingAuthenticatedEncryptionProvider("v2", writer: null!)); } - [Fact] - public void RotateKeyAsync_AlwaysThrows() - { - var migrating = new MigratingEncryptionProvider("v2", new AesGcmEncryptionProvider(NewKeyB64())); - - var method = typeof(MigratingEncryptionProvider).GetMethod(nameof(IEncryptionProvider.RotateKeyAsync), Type.EmptyTypes)!; - var ex = Assert.Throws(() => method.Invoke(migrating, null)); - Assert.IsType(ex.InnerException); - } - [Fact] public void RegisteredTags_IncludesWriterAndReaders() {