Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [Unreleased]

### Changed

- Secret payloads (the decrypted value of `Secret<T>`) 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ public static Command Create()

var valueOption = new Option<string?>("--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");
Expand Down
5 changes: 3 additions & 2 deletions src/Cocoar.Configuration/Secrets/SecretTypes/Secret.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ public sealed class Secret<T> : ISecret<T>
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
}
Expand Down Expand Up @@ -132,7 +133,7 @@ private static SecretLease<T> 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<T>(new ReadOnlySpan<byte>(bytes));
var value = JsonSerializer.Deserialize<T>(new ReadOnlySpan<byte>(bytes), SecretValueSerialization.Options);

if (value is null)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Cocoar.Configuration.Secrets.SecretTypes;

/// <summary>
/// JSON options for (de)serializing a secret's decrypted plaintext <em>value</em> (the payload of
/// <c>Secret&lt;T&gt;</c>), kept in one place so the serialize and deserialize sides stay symmetric.
/// <para>
/// These are intentionally lenient — matching the configuration pipeline's conventions rather than the
/// stricter <see cref="JsonSerializerOptions.Default"/>:
/// </para>
/// <list type="bullet">
/// <item><description><see cref="JsonSerializerOptions.PropertyNameCaseInsensitive"/> = true, so payloads
/// produced by external encryptors (CLI, a browser, hand-edited files) bind regardless of casing.</description></item>
/// <item><description><see cref="JsonStringEnumConverter"/>, so enums serialize as their <em>names</em>
/// (round-trip-safe if the enum is later reordered) while still <em>reading</em> both names and numbers —
/// which keeps older envelopes (enum-as-number) decryptable.</description></item>
/// </list>
/// <para>
/// Only the type-aware paths inside the library serialize a typed value (<c>Secret&lt;T&gt;</c>); anything
/// coming from outside is plain JSON, which is exactly why a name-based, case-insensitive contract is the
/// safer default here.
/// </para>
/// </summary>
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;
}
}
Original file line number Diff line number Diff line change
@@ -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<Tier>("\"Pro\"", SecretValueSerialization.Options));

[Fact]
public void Options_Enum_ReadsNumber_StaysBackwardCompatible()
=> Assert.Equal(Tier.Pro, JsonSerializer.Deserialize<Tier>("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<Plan>(
"{\"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<Tier>.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<Plan>.FromPlain(plan);
using var lease = secret.Open();
Assert.Equal(plan, lease.Value);
}

[Fact]
public void FromPlain_NullableEnum_RoundTrips()
{
var secret = Secret<Tier?>.FromPlain(Tier.Free);
using var lease = secret.Open();
Assert.Equal(Tier.Free, lease.Value);
}
}
9 changes: 9 additions & 0 deletions website/changelog.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading