diff --git a/CHANGELOG.md b/CHANGELOG.md index fec632c..678e684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Changed + +- Secret payloads (the decrypted value of `Secret`) now (de)serialize with lenient options: **enums as names** (round-trip-safe if the enum is later reordered) and **case-insensitive** property matching. Reading still accepts numeric enums and any casing, so **existing encrypted secrets remain fully readable** — no migration. Only the in-memory form of newly serialized typed secret values changes (enum name instead of ordinal); encrypted envelopes at rest are unaffected. + ## [5.0.0] - 2026-03-24 ### Added diff --git a/src/Cocoar.Configuration.Secrets.Cli/Commands/EncryptCommand.cs b/src/Cocoar.Configuration.Secrets.Cli/Commands/EncryptCommand.cs index b0cbdc2..edef562 100644 --- a/src/Cocoar.Configuration.Secrets.Cli/Commands/EncryptCommand.cs +++ b/src/Cocoar.Configuration.Secrets.Cli/Commands/EncryptCommand.cs @@ -26,7 +26,8 @@ public static Command Create() var valueOption = new Option("--value") { - Description = "The plaintext value to encrypt. If omitted, encrypts the existing value at the specified path.", + Description = "The plaintext value to encrypt. If omitted, encrypts the existing value at the specified path. " + + "For enum values, pass the name (e.g. 'Active') rather than the number — names survive enum reordering.", Required = false }; valueOption.Aliases.Add("-v"); diff --git a/src/Cocoar.Configuration/Secrets/SecretTypes/Secret.cs b/src/Cocoar.Configuration/Secrets/SecretTypes/Secret.cs index 239cdb2..6852c93 100644 --- a/src/Cocoar.Configuration/Secrets/SecretTypes/Secret.cs +++ b/src/Cocoar.Configuration/Secrets/SecretTypes/Secret.cs @@ -20,7 +20,8 @@ public sealed class Secret : ISecret internal Secret(T plain, SecretsDecryptorResolver? resolver = null, bool allowPlaintext = false) { // Serialize directly to UTF-8 bytes — never create an intermediate string. - _plainBytes = JsonSerializer.SerializeToUtf8Bytes(plain); + // Lenient options (enums as names) keep the payload round-trip-safe and consistent with the read side. + _plainBytes = JsonSerializer.SerializeToUtf8Bytes(plain, SecretValueSerialization.Options); _resolver = resolver; _blockPlaintextAccess = !allowPlaintext; // Block access if plaintext is NOT explicitly allowed } @@ -132,7 +133,7 @@ private static SecretLease DeserializeAndCreateLease(byte[] bytes, bool needs // Security at rest: before Open(), secrets exist only as encrypted envelopes. // At Open() time, the consumer needs the value (to pass to HttpClient, DB, etc.) // so converting to T is unavoidable. The decrypted bytes are zeroed on lease dispose. - var value = JsonSerializer.Deserialize(new ReadOnlySpan(bytes)); + var value = JsonSerializer.Deserialize(new ReadOnlySpan(bytes), SecretValueSerialization.Options); if (value is null) { diff --git a/src/Cocoar.Configuration/Secrets/SecretTypes/SecretValueSerialization.cs b/src/Cocoar.Configuration/Secrets/SecretTypes/SecretValueSerialization.cs new file mode 100644 index 0000000..fdfe9e1 --- /dev/null +++ b/src/Cocoar.Configuration/Secrets/SecretTypes/SecretValueSerialization.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Cocoar.Configuration.Secrets.SecretTypes; + +/// +/// JSON options for (de)serializing a secret's decrypted plaintext value (the payload of +/// Secret<T>), kept in one place so the serialize and deserialize sides stay symmetric. +/// +/// These are intentionally lenient — matching the configuration pipeline's conventions rather than the +/// stricter : +/// +/// +/// = true, so payloads +/// produced by external encryptors (CLI, a browser, hand-edited files) bind regardless of casing. +/// , so enums serialize as their names +/// (round-trip-safe if the enum is later reordered) while still reading both names and numbers — +/// which keeps older envelopes (enum-as-number) decryptable. +/// +/// +/// Only the type-aware paths inside the library serialize a typed value (Secret<T>); anything +/// coming from outside is plain JSON, which is exactly why a name-based, case-insensitive contract is the +/// safer default here. +/// +/// +internal static class SecretValueSerialization +{ + internal static readonly JsonSerializerOptions Options = Create(); + + private static JsonSerializerOptions Create() + { + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + }; + options.Converters.Add(new JsonStringEnumConverter()); + return options; + } +} diff --git a/src/tests/Cocoar.Configuration.Secrets.Tests/SecretEnumSerializationTests.cs b/src/tests/Cocoar.Configuration.Secrets.Tests/SecretEnumSerializationTests.cs new file mode 100644 index 0000000..bbd3339 --- /dev/null +++ b/src/tests/Cocoar.Configuration.Secrets.Tests/SecretEnumSerializationTests.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using Cocoar.Configuration.Secrets.SecretTypes; +using Xunit; + +namespace Cocoar.Configuration.Secrets.Tests; + +public class SecretEnumSerializationTests +{ + public enum Tier { Free = 0, Pro = 1, Enterprise = 2 } + + public record Plan(string Name, Tier Tier); + + // ---- options-level: the decrypted-envelope deserialize path uses these exact options ---- + + [Fact] + public void Options_Enum_ReadsName() + => Assert.Equal(Tier.Pro, JsonSerializer.Deserialize("\"Pro\"", SecretValueSerialization.Options)); + + [Fact] + public void Options_Enum_ReadsNumber_StaysBackwardCompatible() + => Assert.Equal(Tier.Pro, JsonSerializer.Deserialize("1", SecretValueSerialization.Options)); + + [Fact] + public void Options_Enum_WritesName_NotOrdinal() + => Assert.Equal("\"Pro\"", JsonSerializer.Serialize(Tier.Pro, SecretValueSerialization.Options)); + + [Fact] + public void Options_Object_IsCaseInsensitive_AndReadsEnumName() + { + var plan = JsonSerializer.Deserialize( + "{\"name\":\"acme\",\"tier\":\"Enterprise\"}", SecretValueSerialization.Options); + + Assert.NotNull(plan); + Assert.Equal("acme", plan!.Name); + Assert.Equal(Tier.Enterprise, plan.Tier); + } + + // ---- end-to-end via FromPlain (exercises both the ctor serialize and the Open deserialize) ---- + + [Fact] + public void FromPlain_Enum_RoundTrips() + { + var secret = Secret.FromPlain(Tier.Enterprise); + using var lease = secret.Open(); + Assert.Equal(Tier.Enterprise, lease.Value); + } + + [Fact] + public void FromPlain_RecordWithEnum_RoundTrips() + { + var plan = new Plan("Acme", Tier.Pro); + var secret = Secret.FromPlain(plan); + using var lease = secret.Open(); + Assert.Equal(plan, lease.Value); + } + + [Fact] + public void FromPlain_NullableEnum_RoundTrips() + { + var secret = Secret.FromPlain(Tier.Free); + using var lease = secret.Open(); + Assert.Equal(Tier.Free, lease.Value); + } +} diff --git a/website/changelog.md b/website/changelog.md index a7b45a8..6cf1e5e 100644 --- a/website/changelog.md +++ b/website/changelog.md @@ -1,5 +1,14 @@ # Changelog +## [Unreleased] + +### Changed + +**Secrets — robust enum & casing handling** +- Secret payloads now (de)serialize with lenient options: **enums as names** (safe against enum reordering) and **case-insensitive** property matching. +- Reading still accepts numeric enums and any casing → **existing encrypted secrets remain fully readable**, no migration. +- Recommendation: when encrypting an enum secret with the CLI, pass the **name** (e.g. `Active`) rather than the ordinal. + ## [5.0.0] — 2026-03-24 ### Added