Skip to content
Closed
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,27 @@

## [Unreleased]

### Added

- **LocalStorage provider** — a writable, application-controlled override layer for *overridable defaults*: the normal sources (files, environment, …) supply defaults, and the application overrides individual values at runtime.
- `ILocalStorage<T>` (type-safe facade) and `ILocalStorageOverlay<T>` (raw key-path surface) in `Cocoar.Configuration.Abstractions`
- **Sparse writes** — `SetAsync(x => x.Smtp.Port, value)` persists only the touched leaf; unset keys keep inheriting from the lower layers
- `ResetAsync(...)` removes an override (value falls back to the inherited default); an explicit `null` override is distinct from reset
- `DescribeAsync()` returns per-key provenance (`OverrideEntry`: base value, effective value, `IsOverridden`) for management UIs
- `.FromLocalStorage()` rule extension; file-based backend by default with a pluggable `IStorageBackend`
- `ILocalStorage<T>` / `ILocalStorageOverlay<T>` are DI-injectable (single shared singleton) — write your own endpoints with your own validation/normalization/logging
- LocalStorageOverride example project
- `IProviderServiceRegistration` now supports resolve-time factory registrations (`ProviderServiceRegistration.Singleton(type, factory)`) in addition to eager instances

### 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.

### Notes

- Secret-typed members (`Secret<T>` / `ISecret<T>`) cannot be overridden via LocalStorage — the typed facade throws `NotSupportedException` (manage secrets via the Secrets CLI/provider).
- Overlay values serialize with vanilla options (enums as strings) and overlay keys are aligned to the lower layers' casing, so an override **replaces** the base key rather than creating a casing-variant sibling.

## [5.0.0] - 2026-03-24

### Added
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ app.Run();
| Environment Variables | `.FromEnvironment("PREFIX_")` | Core |
| Command Line | `.FromCommandLine("--prefix")` | Core |
| Static / Observable | `.FromStaticJson()` / `.FromObservable()` | Core |
| LocalStorage (writable overlay) | `.FromLocalStorage()` | Core |
| HTTP | `.FromHttp(url)` | [Http](https://www.nuget.org/packages/Cocoar.Configuration.Http) |
| Microsoft IConfiguration | `.FromIConfiguration(config)` | [MicrosoftAdapter](https://www.nuget.org/packages/Cocoar.Configuration.MicrosoftAdapter) |

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using System.Linq.Expressions;
using System.Text.Json;
using Cocoar.Configuration.Secrets.SecretTypes;

namespace Cocoar.Configuration.LocalStorage;

/// <summary>
/// Type-safe facade for a LocalStorage override layer over configuration type <typeparamref name="T"/>.
/// <para>
/// LocalStorage supplies <em>overridable defaults</em>: the normal sources (files, environment, …) provide
/// defaults, and the application overrides individual values at runtime. Writes are <em>sparse</em> — only
/// the keys you set are persisted, everything else continues to inherit from the lower layers. A write
/// triggers the normal recompute, so <c>IReactiveConfig&lt;T&gt;</c> emits the new effective value.
/// </para>
/// <para>
/// Secret-typed members (<c>Secret&lt;T&gt;</c> / <c>ISecret&lt;T&gt;</c>) cannot be overridden through this
/// API and throw <see cref="NotSupportedException"/>; manage secrets via the Secrets CLI/provider.
/// </para>
/// </summary>
/// <typeparam name="T">The configuration type this overlay targets.</typeparam>
public interface ILocalStorage<T> where T : class
{
/// <summary>
/// Overrides a single value selected by a member-access expression (e.g. <c>x => x.Smtp.Port</c>),
/// persisting only that leaf. The member chain is translated into a dotted key path and the value is
/// serialized with vanilla options (enums as strings); the lower layers are not otherwise touched.
/// </summary>
/// <exception cref="NotSupportedException">
/// The selector is not a simple member-access chain (e.g. it contains an indexer, method call, or cast),
/// or it targets a secret-typed member.
/// </exception>
Task SetAsync<TValue>(Expression<Func<T, TValue>> selector, TValue value, CancellationToken ct = default);

/// <summary>
/// Sets a <em>pre-encrypted</em> secret envelope for a secret-typed member (e.g. <c>x => x.ApiKey</c>),
/// encrypted client-side with the server's public certificate so plaintext never reaches the server.
/// The selector must point at a <c>Secret&lt;TSecret&gt;</c> / <c>ISecret&lt;TSecret&gt;</c> member, and the
/// <see cref="SecretEnvelope{T}"/> must carry the same <typeparamref name="TSecret"/> — so the envelope and
/// the target secret are matched at compile time. The normal <see cref="SetAsync{TValue}"/> still rejects
/// secret members (and objects containing secrets) to prevent storing plaintext.
/// </summary>
/// <exception cref="ArgumentException">The envelope is not a well-formed <c>cocoar.secret</c> envelope.</exception>
Task SetSecretAsync<TSecret>(Expression<Func<T, ISecret<TSecret>>> selector, SecretEnvelope<TSecret> envelope, CancellationToken ct = default);

/// <summary>
/// Resets a single value to its inherited (lower-layer) value by removing that leaf from the overlay.
/// </summary>
/// <returns><see langword="true"/> if an override was removed; <see langword="false"/> if none existed.</returns>
Task<bool> ResetAsync<TValue>(Expression<Func<T, TValue>> selector, CancellationToken ct = default);

/// <summary>
/// Resets everything this layer overrides, so all keys inherit from the lower layers again.
/// </summary>
Task ClearAsync(CancellationToken ct = default);

/// <summary>
/// Reads the sparse overlay into a partial <typeparamref name="T"/> where unset properties take their C#
/// defaults. Returns <see langword="null"/> when nothing is overridden. For the merged/effective value,
/// use <c>IReactiveConfig&lt;T&gt;.CurrentValue</c> or <c>IConfigurationAccessor.GetConfig&lt;T&gt;()</c>.
/// </summary>
Task<T?> ReadAsync(CancellationToken ct = default);

/// <summary>
/// Returns per-key provenance for a management UI: the union of leaf paths from the base (lower layers,
/// without this overlay) and the overlay, each annotated with its base value, effective value, and whether
/// it is currently overridden.
/// </summary>
Task<IReadOnlyList<OverrideEntry>> DescribeAsync(CancellationToken ct = default);

/// <summary>
/// The raw, key-path overlay surface for dynamic or non-expressible paths.
/// </summary>
ILocalStorageOverlay<T> Overlay { get; }
}

/// <summary>
/// Per-leaf provenance entry produced by <see cref="ILocalStorage{T}.DescribeAsync"/>.
/// </summary>
/// <param name="KeyPath">Dotted leaf path (e.g. <c>"Smtp.Port"</c>).</param>
/// <param name="BaseValue">The value from the lower layers, without this overlay; <see langword="null"/> if absent there.</param>
/// <param name="EffectiveValue">The merged/effective value seen by the application; <see langword="null"/> if absent.</param>
/// <param name="IsOverridden"><see langword="true"/> when the overlay supplies this key.</param>
public sealed record OverrideEntry(
string KeyPath,
JsonElement? BaseValue,
JsonElement? EffectiveValue,
bool IsOverridden);
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System.Text.Json.Nodes;

namespace Cocoar.Configuration.LocalStorage;

/// <summary>
/// Raw, key-path patch surface for a LocalStorage override layer.
/// <para>
/// LocalStorage contributes a <em>sparse</em> overlay: only the leaf keys it explicitly contains
/// override the lower configuration layers (files, environment, …). Keys that are absent from the
/// overlay inherit their value from those lower layers. This interface is the dependency-free
/// (<c>JsonNode</c>-based) escape hatch used by the typed <see cref="ILocalStorage{T}"/> facade and by
/// callers that need to address dynamic / non-expressible paths.
/// </para>
/// <para>
/// Segments of the key path must match the JSON property names used by the lower layers.
/// The typed facade resolves these automatically; when using this raw surface directly, the caller is
/// responsible for the names. Do not use this surface for secret-typed paths — see <see cref="ILocalStorage{T}"/>.
/// </para>
/// </summary>
/// <typeparam name="T">The configuration type this overlay targets.</typeparam>
public interface ILocalStorageOverlay<T> where T : class
{
/// <summary>
/// Sets a sparse, dotted key path (e.g. <c>"Smtp.Port"</c>) to a JSON value, persisting only that leaf.
/// </summary>
/// <param name="keyPath">Dotted path whose segments match the persisted JSON property names.</param>
/// <param name="value">
/// The JSON value to store at the leaf. A <see langword="null"/> reference writes an explicit JSON
/// <c>null</c> override (clobbers the lower-layer value to <c>null</c>) — this is distinct from
/// <see cref="ResetAsync"/>, which removes the override entirely.
/// </param>
/// <param name="ct">A token to cancel the write.</param>
Task SetAsync(string keyPath, JsonNode? value, CancellationToken ct = default);

/// <summary>
/// Sets a <em>pre-encrypted</em> secret envelope at a dotted key path. The value MUST be a well-formed
/// <c>cocoar.secret</c> envelope (produced client-side with the server's public certificate, or by the
/// Secrets CLI); plaintext and the masked <c>"***"</c> form are rejected, so the secret never reaches the
/// server in the clear.
/// </summary>
/// <param name="keyPath">Dotted path to the secret member.</param>
/// <param name="envelope">A well-formed encrypted secret envelope (object with <c>type</c>=<c>"cocoar.secret"</c>).</param>
/// <param name="ct">A token to cancel the write.</param>
/// <exception cref="ArgumentException">The value is not a well-formed encrypted secret envelope.</exception>
Task SetSecretEnvelopeAsync(string keyPath, JsonNode envelope, CancellationToken ct = default);

/// <summary>
/// Removes a key path from the overlay so the value falls back to the lower-layer (inherited) value.
/// </summary>
/// <returns><see langword="true"/> if a key was removed; <see langword="false"/> if it was already absent.</returns>
Task<bool> ResetAsync(string keyPath, CancellationToken ct = default);

/// <summary>
/// Clears the entire overlay, persisting an empty object so every key inherits again.
/// </summary>
Task ClearAsync(CancellationToken ct = default);

/// <summary>
/// Reads the raw sparse overlay exactly as persisted (the override fragment, NOT the merged result).
/// Returns <see langword="null"/> when the overlay is empty.
/// </summary>
Task<JsonNode?> ReadOverlayAsync(CancellationToken ct = default);
}
91 changes: 91 additions & 0 deletions src/Cocoar.Configuration.Abstractions/SecretEncryptionKey.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using System.Text.Json.Serialization;

namespace Cocoar.Configuration.Secrets.SecretTypes;

/// <summary>
/// Canonical algorithm identifiers for the <c>cocoar.secret</c> hybrid encryption scheme.
/// A single source of truth shared by <see cref="SecretEnvelope{T}"/> and the published keys.
/// </summary>
public static class SecretAlgorithms
{
/// <summary>Combined hybrid algorithm descriptor. <c>"RSA-OAEP-AES256-GCM"</c>.</summary>
public const string Hybrid = "RSA-OAEP-AES256-GCM";

/// <summary>RSA key-wrapping algorithm (OAEP with SHA-256). <c>"RSA-OAEP-256"</c>.</summary>
public const string KeyWrap = "RSA-OAEP-256";

/// <summary>Symmetric data-encryption algorithm. <c>"AES-256-GCM"</c>.</summary>
public const string DataEncryption = "AES-256-GCM";
}

/// <summary>
/// The current public encryption key for one <c>kid</c> — the X.509 SubjectPublicKeyInfo a producer
/// (e.g. the <c>@cocoar/secrets</c> browser library) imports to build a matching <c>cocoar.secret</c>
/// envelope. Carries only public material; never a private key.
/// </summary>
public sealed record SecretEncryptionPublicKey
{
/// <summary>Key identifier the producer stamps into the envelope <c>kid</c> field.</summary>
[JsonPropertyName("kid")]
public required string Kid { get; init; }

/// <summary>Overall algorithm. <c>"RSA-OAEP-AES256-GCM"</c>.</summary>
[JsonPropertyName("alg")]
public string Alg { get; init; } = SecretAlgorithms.Hybrid;

/// <summary>Key-wrapping algorithm. <c>"RSA-OAEP-256"</c>.</summary>
[JsonPropertyName("walg")]
public string Walg { get; init; } = SecretAlgorithms.KeyWrap;

/// <summary>Data-encryption algorithm. <c>"AES-256-GCM"</c>.</summary>
[JsonPropertyName("enc")]
public string Enc { get; init; } = SecretAlgorithms.DataEncryption;

/// <summary>Public-key structure. Always <c>"spki"</c> (X.509 SubjectPublicKeyInfo, DER).</summary>
[JsonPropertyName("format")]
public string Format { get; init; } = "spki";

/// <summary>Encoding of <see cref="PublicKey"/>. Always <c>"base64url"</c> (no padding).</summary>
[JsonPropertyName("encoding")]
public string Encoding { get; init; } = "base64url";

/// <summary>The RSA public key as DER SubjectPublicKeyInfo, base64url-encoded WITHOUT padding.</summary>
[JsonPropertyName("publicKey")]
public required string PublicKey { get; init; }
}

/// <summary>
/// The published set of current encryption public keys (one per kid) — the wire shape of the list
/// endpoint: <c>{ "keys": [ ... ] }</c>. The <c>keys</c> field name is pinned via
/// <see cref="JsonPropertyNameAttribute"/> so a host JSON naming policy cannot rename it.
/// </summary>
public sealed record SecretEncryptionKeySet
{
/// <summary>The current encryption public key for each configured kid. Never null; may be empty.</summary>
[JsonPropertyName("keys")]
public IReadOnlyList<SecretEncryptionPublicKey> Keys { get; init; } = Array.Empty<SecretEncryptionPublicKey>();
}

/// <summary>
/// Publishes the public half of the configured secrets encryption key(s) so external producers can
/// build <c>cocoar.secret</c> envelopes the server can later decrypt. Resolved from dependency injection.
/// <para>
/// There is exactly ONE current key per <c>kid</c> — the certificate the decryption engine prefers.
/// Implementations re-read key material on every call so certificate rotation is reflected. Public keys
/// are safe to expose; no private key or plaintext is ever reachable through this API.
/// </para>
/// </summary>
public interface ISecretEncryptionKeyProvider
{
/// <summary>
/// Returns the current encryption public key for each configured kid (one per kid). Returns an
/// empty list when nothing is publishable; never throws for the "no keys" case.
/// </summary>
IReadOnlyList<SecretEncryptionPublicKey> GetCurrentKeys();

/// <summary>
/// The current encryption public key for <paramref name="kid"/>, or <see langword="null"/> if that
/// kid is not currently published.
/// </summary>
SecretEncryptionPublicKey? GetCurrentKey(string kid);
}
58 changes: 58 additions & 0 deletions src/Cocoar.Configuration.Abstractions/SecretEnvelope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using System.Text.Json.Serialization;

namespace Cocoar.Configuration.Secrets.SecretTypes;

/// <summary>
/// The wire / transport form of an encrypted secret — a <c>cocoar.secret</c> envelope produced
/// client-side (e.g. by the <c>@cocoar/secrets</c> browser library) or by the Secrets CLI.
/// <para>
/// This is NOT a usable secret: it is ciphertext to be stored, and it cannot be opened without the
/// private key. It is deliberately a plain, JSON-bindable record (so an API request DTO can carry it)
/// — it does <em>not</em> inherit from <c>Secret&lt;T&gt;</c>, which has a different role (an openable
/// runtime secret). The phantom <typeparamref name="T"/> couples the envelope to the value type of the
/// target <c>Secret&lt;T&gt;</c> so the compiler can match them at the write call site.
/// </para>
/// <para>
/// Binary fields (<see cref="Wk"/>, <see cref="Iv"/>, <see cref="Ct"/>, <see cref="Tag"/>) are
/// base64url WITHOUT padding — the encoding the decryption path requires.
/// </para>
/// </summary>
/// <typeparam name="T">The value type of the secret this envelope encrypts (phantom; for typing only).</typeparam>
public sealed record SecretEnvelope<T>
{
/// <summary>Envelope discriminator. Always <c>"cocoar.secret"</c>.</summary>
[JsonPropertyName("type")]
public string Type { get; init; } = "cocoar.secret";

/// <summary>Envelope format version. Always <c>1</c>.</summary>
[JsonPropertyName("version")]
public int Version { get; init; } = 1;

/// <summary>Key identifier — must match a decryption key configured on the server.</summary>
[JsonPropertyName("kid")]
public required string Kid { get; init; }

/// <summary>Overall algorithm. <c>"RSA-OAEP-AES256-GCM"</c>.</summary>
[JsonPropertyName("alg")]
public string Alg { get; init; } = SecretAlgorithms.Hybrid;

/// <summary>The AES-256 key wrapped with RSA-OAEP-SHA256 (base64url, no padding).</summary>
[JsonPropertyName("wk")]
public required string Wk { get; init; }

/// <summary>Key-wrapping algorithm. <c>"RSA-OAEP-256"</c>.</summary>
[JsonPropertyName("walg")]
public string Walg { get; init; } = SecretAlgorithms.KeyWrap;

/// <summary>AES-GCM 96-bit initialization vector (base64url, no padding).</summary>
[JsonPropertyName("iv")]
public required string Iv { get; init; }

/// <summary>AES-GCM ciphertext (base64url, no padding).</summary>
[JsonPropertyName("ct")]
public required string Ct { get; init; }

/// <summary>AES-GCM 128-bit authentication tag (base64url, no padding).</summary>
[JsonPropertyName("tag")]
public required string Tag { get; init; }
}
Loading
Loading