merge: ZB.MOM.WW.Secrets envelope-encrypted secrets manager (lib + Blazor UI + CLI)

7th shared ZB.MOM.WW.* library: AES-256-GCM envelope-encrypted secret store
(ISecretStore/SQLite), pluggable IMasterKeyProvider, ${secret:name} config
resolution, ISecretResolver runtime cache, Blazor /admin/secrets management UI
on the Theme kit, and a headless 'secret' CLI. Published 0.1.2 to the Gitea feed;
HistorianGateway adopted as reference consumer (live-proven end-to-end against
the real wonder historian). 80 tests pass.
This commit is contained in:
Joseph Doherty
2026-07-16 04:44:01 -04:00
72 changed files with 5659 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<Version>0.1.2</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<!-- Embed the repo README in each packable nupkg (ignored by non-packable projects). -->
<ItemGroup Condition="'$(IsPackable)' == 'true'">
<None Include="$(MSBuildThisFileDirectory)README.md" Pack="true" PackagePath="\" Visible="false" />
</ItemGroup>
</Project>
@@ -0,0 +1,45 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<!-- LDAP -->
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
<!-- Data -->
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.7" />
<!-- Cryptography -->
<PackageVersion Include="System.Security.Cryptography.ProtectedData" Version="9.0.0" />
<!-- Extensions -->
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.7" />
<!-- ASP.NET Core Authentication / Authorization -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
<!-- ZB.MOM.WW shared libraries (Gitea feed) -->
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.2.0" />
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.4" />
<!-- Test -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.61" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="bunit" Version="1.40.0" />
</ItemGroup>
</Project>
+67
View File
@@ -0,0 +1,67 @@
# ZB.MOM.WW.Secrets
A reusable, envelope-encrypted **secrets manager** for the `ZB.MOM.WW.*` SCADA family:
store secrets (SQL passwords, API tokens, connection strings) encrypted at rest and get the
plaintext back on demand — from application code, from configuration, or from an operator UI.
## Packages
| Package | Purpose |
|---|---|
| `ZB.MOM.WW.Secrets.Abstractions` | Contracts only (`ISecretStore`, `ISecretResolver`, `IMasterKeyProvider`, `ISecretCipher`, `ISecretReplicator`, `ISecretCacheInvalidator`, `ISecretActorAccessor`), value types, exceptions. Dependency-light. |
| `ZB.MOM.WW.Secrets` | Implementation: AES-256-GCM envelope cipher, env/file/DPAPI master-key providers, SQLite store, TTL resolver (audited), `${secret:}` config expander, `AddZbSecrets`. |
| `ZB.MOM.WW.Secrets.Ui` | Blazor RCL on `ZB.MOM.WW.Theme`: list / add / rotate / delete + policy-gated, audited reveal. |
A `secret` CLI (`set` / `get` / `list` / `rm` / `rotate`) ships in the repo (not packed).
## How it protects secrets
Each value is encrypted with a fresh **data key (DEK)** using AES-256-GCM; the DEK is then
**wrapped** by a **master KEK** that never touches the database. The AAD binds the ciphertext
to the secret's name and the wrapped DEK to the KEK id, so rows can't be swapped and a
tampered/mis-keyed row **fails closed** (`SecretDecryptionException`) rather than returning a
wrong value. The master KEK is resolved through a pluggable `IMasterKeyProvider`
(environment variable, key file, or Windows DPAPI).
## Wiring it up
```csharp
builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
// After config load, BEFORE options validation — expand ${secret:...} references:
var expander = new SecretReferenceExpander(app.Services.GetRequiredService<ISecretResolver>());
await expander.ExpandConfigurationAsync((IConfigurationRoot)builder.Configuration, ct);
```
```jsonc
// appsettings.json
"Secrets": {
"SqlitePath": "secrets.db",
"MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" },
"RunMigrationsOnStartup": true,
"ResolveCacheTtl": "00:00:30"
},
// Keep plaintext out of config — reference a stored secret instead:
"ConnectionStrings": {
"Historian": "Server=...;User Id=sa;Password=${secret:sql/historian-password}"
}
```
The master key is 32 bytes, provided base64 in `ZB_SECRETS_MASTER_KEY` (Environment source).
A missing/invalid key fails closed at startup (`MasterKeyUnavailableException`).
## Runtime + human access
- **App code:** inject `ISecretResolver` and call `GetAsync(name, ct)`. Every resolve is
audited via `ZB.MOM.WW.Audit` (name + outcome only — the value is never logged).
- **Operators:** mount the `ZB.MOM.WW.Secrets.Ui` `/admin/secrets` page. The list shows
**metadata only**; revealing a value requires the `secrets:reveal` authorization policy and
is audited. Add/rotate/delete require `secrets:manage`.
## Clustered deployments
Secrets storage is local SQLite by default. The schema already carries the
`revision` / `updated_utc` / tombstone columns and an `ISecretReplicator` seam for
cluster replication, but the Akka replicator (`ZB.MOM.WW.Secrets.Akka`) is a deferred
follow-on. **When replicating across a node pair, every node must resolve the _same_ master
KEK** (e.g. the same mounted key file) — a row wrapped by an unknown KEK fails closed.
+12
View File
@@ -0,0 +1,12 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/ZB.MOM.WW.Secrets.Abstractions/ZB.MOM.WW.Secrets.Abstractions.csproj" />
<Project Path="src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj" />
<Project Path="src/ZB.MOM.WW.Secrets.Ui/ZB.MOM.WW.Secrets.Ui.csproj" />
<Project Path="src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj" />
<Project Path="tests/ZB.MOM.WW.Secrets.Ui.Tests/ZB.MOM.WW.Secrets.Ui.Tests.csproj" />
</Folder>
</Solution>
@@ -0,0 +1,24 @@
namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>
/// Supplies the AES-256 master key (KEK) used to wrap and unwrap per-secret data-encryption
/// keys. Implementations resolve the key material from a configured source (environment,
/// file, external KMS) and expose a stable, non-secret identifier for the active key so
/// rotation can be recorded per row and reasoned about later.
/// </summary>
public interface IMasterKeyProvider
{
/// <summary>
/// Returns the 32-byte AES-256 master key material.
/// </summary>
/// <returns>The 32-byte master key.</returns>
/// <exception cref="MasterKeyUnavailableException">The master key cannot be resolved.</exception>
ReadOnlyMemory<byte> GetMasterKey();
/// <summary>
/// The stable, non-secret identifier of the active key-encryption key (KEK). This value
/// is recorded on each stored row so that key rotation is legible and rows can be matched
/// to the KEK that wrapped their DEK.
/// </summary>
string KekId { get; }
}
@@ -0,0 +1,19 @@
namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>
/// Resolves the current principal (the ZB.MOM.WW.Auth identity) for audit attribution when a
/// secret is resolved. A consumer wires this to whatever surfaces the ambient caller — an HTTP
/// context accessor, an actor context, a CLI identity, etc. When <see cref="CurrentActor"/> is
/// <c>null</c> (or no accessor is registered), the fallback actor string is consumer-defined so an
/// action is still attributed rather than left blank — e.g. <c>"system"</c> for background/keyless
/// resolution, or <c>"unknown"</c> for an unauthenticated UI action (the value the UI's
/// <c>SecretActorResolver</c> uses).
/// </summary>
public interface ISecretActorAccessor
{
/// <summary>
/// The current principal to record as the audit <c>Actor</c>, or <c>null</c> when no
/// principal is available (the resolver then substitutes its consumer-defined fallback actor).
/// </summary>
string? CurrentActor { get; }
}
@@ -0,0 +1,8 @@
namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>Evicts a cached decrypted secret so the next resolve re-reads it. Called by write paths after a rotate/delete.</summary>
public interface ISecretCacheInvalidator
{
/// <summary>Evicts the cache entry for <paramref name="name"/> (no-op if absent).</summary>
void Invalidate(SecretName name);
}
@@ -0,0 +1,35 @@
namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>
/// Envelope-encrypts and decrypts secret material. Each secret is sealed under a per-secret
/// data-encryption key (DEK) that is itself wrapped by the master key (KEK) from an
/// <see cref="IMasterKeyProvider"/>. The secret name is bound as additional authenticated
/// data (AAD) so ciphertext cannot be silently relocated to a different name.
/// </summary>
public interface ISecretCipher
{
/// <summary>
/// Envelope-encrypts <paramref name="plaintext"/>, binding it to <paramref name="name"/>
/// as additional authenticated data.
/// </summary>
/// <param name="name">The secret name, bound as AAD.</param>
/// <param name="plaintext">The secret plaintext to seal.</param>
/// <param name="contentType">How the plaintext should be interpreted by consumers.</param>
/// <returns>
/// A <see cref="StoredSecret"/> carrying the ciphertext, nonce, tag, and wrapped DEK, with
/// <see cref="StoredSecret.Revision"/> 0 and default timestamps — the store stamps the
/// revision and timestamps on persist.
/// </returns>
StoredSecret Encrypt(SecretName name, string plaintext, SecretContentType contentType);
/// <summary>
/// Decrypts the value carried by a stored row, verifying its authentication tag and the
/// name it was bound to.
/// </summary>
/// <param name="secret">The stored row to decrypt.</param>
/// <returns>The decrypted plaintext.</returns>
/// <exception cref="SecretDecryptionException">
/// The authentication tag fails to verify, or the row references an unknown or unavailable KEK.
/// </exception>
string Decrypt(StoredSecret secret);
}
@@ -0,0 +1,17 @@
namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>
/// Publishes an encrypted row to cluster peers. Core registers a no-op implementation;
/// <c>ZB.MOM.WW.Secrets.Akka</c> (deferred) provides the real cluster implementation.
/// </summary>
public interface ISecretReplicator
{
/// <summary>
/// Broadcasts the already-encrypted <paramref name="row"/> to peer nodes. This is a no-op
/// in single-node deployments.
/// </summary>
/// <param name="row">The encrypted row to replicate.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>A task that completes when the row has been published.</returns>
Task PublishAsync(StoredSecret row, CancellationToken ct);
}
@@ -0,0 +1,19 @@
namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>
/// The consumer-facing read seam: resolves the current plaintext of a secret by name. This is
/// the surface application code depends on to fetch secrets at runtime; every resolution is
/// audited (the access, never the value).
/// </summary>
public interface ISecretResolver
{
/// <summary>
/// Resolves and decrypts the current plaintext for <paramref name="name"/>.
/// </summary>
/// <param name="name">The secret to resolve.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>
/// The decrypted plaintext, or <c>null</c> if the secret is absent or tombstoned.
/// </returns>
Task<string?> GetAsync(SecretName name, CancellationToken ct);
}
@@ -0,0 +1,66 @@
namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>
/// Persists encrypted secret rows and exposes the read, list, and replication operations the
/// core services build on. The store only ever handles the encrypted <see cref="StoredSecret"/>
/// representation and its safe <see cref="SecretMetadata"/> projection — it never sees plaintext.
/// Deletes are soft (tombstones) so removals can propagate across a cluster.
/// </summary>
public interface ISecretStore
{
/// <summary>
/// Returns the row for <paramref name="name"/>, including a tombstoned row, or <c>null</c>
/// if no row exists.
/// </summary>
/// <param name="name">The secret to fetch.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The stored row, or <c>null</c> if absent.</returns>
Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct);
/// <summary>
/// Inserts the row, or overwrites an existing row in place, bumping its revision and
/// updated timestamp.
/// </summary>
/// <param name="row">The encrypted row to persist.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>A task that completes when the row is persisted.</returns>
Task UpsertAsync(StoredSecret row, CancellationToken ct);
/// <summary>
/// Soft-deletes (tombstones) the row for <paramref name="name"/> so the deletion can
/// propagate to peers.
/// </summary>
/// <param name="name">The secret to tombstone.</param>
/// <param name="actor">The principal to record as <c>updated_by</c>, if known.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns><c>true</c> if a row was tombstoned; <c>false</c> if the row was absent.</returns>
Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct);
/// <summary>
/// Lists the safe metadata projection for all secrets — never ciphertext.
/// </summary>
/// <param name="includeDeleted">
/// When <c>true</c>, tombstoned rows are included; otherwise they are omitted.
/// </param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The metadata for the matching secrets.</returns>
Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct);
/// <summary>
/// Returns a compact manifest — name, revision, updated timestamp, and tombstone flag per
/// secret — used for cluster anti-entropy reconciliation without exchanging ciphertext.
/// </summary>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The manifest entries for all secrets.</returns>
Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct);
/// <summary>
/// Applies a row received from a peer using last-writer-wins on
/// (<see cref="StoredSecret.UpdatedUtc"/>, <see cref="StoredSecret.Revision"/>). A row that
/// is not newer than the local row is ignored.
/// </summary>
/// <param name="row">The replicated encrypted row to apply.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>A task that completes when the row has been applied or ignored.</returns>
Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct);
}
@@ -0,0 +1,20 @@
namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>
/// Describes how the plaintext of a secret should be interpreted by consumers. This
/// is metadata only — the stored ciphertext is opaque bytes regardless of content type.
/// </summary>
public enum SecretContentType
{
/// <summary>Arbitrary UTF-8 text (the default).</summary>
Text,
/// <summary>A database or service connection string.</summary>
ConnectionString,
/// <summary>A JSON document.</summary>
Json,
/// <summary>Binary data carried as a Base64-encoded string.</summary>
BinaryBase64,
}
@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>
/// A compact per-secret manifest row used for cluster anti-entropy: it carries just
/// enough state (name, revision, tombstone flag, timestamp) for two nodes to compare
/// inventories and reconcile which secrets are newer without exchanging ciphertext.
/// </summary>
public record SecretManifestEntry
{
/// <summary>The normalized secret name.</summary>
public required SecretName Name { get; init; }
/// <summary>Monotonic revision number used to determine which side is newer.</summary>
public required long Revision { get; init; }
/// <summary>When the secret was last updated (UTC).</summary>
public required DateTimeOffset UpdatedUtc { get; init; }
/// <summary>Whether the secret is soft-deleted (tombstoned).</summary>
public bool IsDeleted { get; init; }
}
@@ -0,0 +1,39 @@
namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>
/// The safe, UI-facing projection of a secret. Deliberately carries <b>no</b>
/// <see cref="byte"/> array members, so neither ciphertext nor plaintext can leak into
/// rendered markup or logs via this type — it is safe to bind directly in management UIs.
/// </summary>
public record SecretMetadata
{
/// <summary>The normalized secret name.</summary>
public required SecretName Name { get; init; }
/// <summary>Optional human-readable description.</summary>
public string? Description { get; init; }
/// <summary>How the decrypted plaintext should be interpreted.</summary>
public required SecretContentType ContentType { get; init; }
/// <summary>Identifier of the key-encryption key (KEK) that wrapped the DEK.</summary>
public required string KekId { get; init; }
/// <summary>Monotonic revision number.</summary>
public required long Revision { get; init; }
/// <summary>Whether the secret is soft-deleted (tombstoned).</summary>
public bool IsDeleted { get; init; }
/// <summary>When the secret was first created (UTC).</summary>
public required DateTimeOffset CreatedUtc { get; init; }
/// <summary>When the secret was last updated (UTC).</summary>
public required DateTimeOffset UpdatedUtc { get; init; }
/// <summary>Principal that created the secret, if known.</summary>
public string? CreatedBy { get; init; }
/// <summary>Principal that last updated the secret, if known.</summary>
public string? UpdatedBy { get; init; }
}
@@ -0,0 +1,77 @@
namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>
/// A validated, normalized secret name. Names are trimmed, lowercased (invariant
/// culture), and constrained to a filesystem-safe allow-list so they can double as
/// path segments and stable identifiers. Normalization happens once, at construction.
/// </summary>
public readonly record struct SecretName
{
private const string AllowedChars = "abcdefghijklmnopqrstuvwxyz0123456789._/-";
/// <summary>
/// Creates a <see cref="SecretName"/> from the given raw value, normalizing then
/// validating it.
/// </summary>
/// <param name="value">The raw name to normalize and validate.</param>
/// <exception cref="ArgumentException">
/// The value is null, empty, or whitespace; contains the path-traversal sequence
/// <c>..</c>; is rooted (starts or ends with <c>/</c>) or contains an empty path
/// segment (<c>//</c>); or contains any character outside <c>[a-z0-9._/-]</c> (after
/// trim and lowercasing).
/// </exception>
public SecretName(string value)
{
string normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(normalized))
{
throw new ArgumentException(
$"Secret name must not be null, empty, or whitespace (was '{value}').",
nameof(value));
}
if (normalized.Contains("..", StringComparison.Ordinal))
{
throw new ArgumentException(
$"Secret name must not contain the path-traversal sequence '..' (was '{value}').",
nameof(value));
}
if (normalized.StartsWith('/') || normalized.EndsWith('/') ||
normalized.Contains("//", StringComparison.Ordinal))
{
throw new ArgumentException(
$"Secret name must not be rooted or contain an empty path segment: it may not start or end with '/' or contain '//' (was '{value}').",
nameof(value));
}
foreach (char c in normalized)
{
if (!AllowedChars.Contains(c, StringComparison.Ordinal))
{
throw new ArgumentException(
$"Secret name contains disallowed character '{c}'; allowed characters are [a-z0-9._/-] (was '{value}').",
nameof(value));
}
}
_value = normalized;
}
private readonly string? _value;
/// <summary>The normalized secret name (trimmed, lowercased, allow-list validated).</summary>
/// <exception cref="InvalidOperationException">The instance is the uninitialized <c>default</c> struct value.</exception>
public string Value => _value ?? throw new InvalidOperationException("SecretName is uninitialized (default struct value).");
/// <summary>Returns the normalized <see cref="Value"/>.</summary>
/// <returns>The normalized secret name.</returns>
/// <exception cref="InvalidOperationException">The instance is the uninitialized <c>default</c> struct value.</exception>
public override string ToString() => Value;
/// <summary>Implicitly converts a <see cref="SecretName"/> to its underlying string value.</summary>
/// <param name="name">The secret name to convert.</param>
/// <exception cref="InvalidOperationException">The instance is the uninitialized <c>default</c> struct value.</exception>
public static implicit operator string(SecretName name) => name.Value;
}
@@ -0,0 +1,23 @@
namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>Thrown when the master key (KEK) required to wrap or unwrap a DEK is unavailable.</summary>
public sealed class MasterKeyUnavailableException(string message) : InvalidOperationException(message);
/// <summary>Thrown when a secret's ciphertext cannot be decrypted or its authentication tag fails to verify.</summary>
public sealed class SecretDecryptionException : InvalidOperationException
{
/// <summary>Creates the exception with a message describing the decryption failure.</summary>
/// <param name="message">The failure description.</param>
public SecretDecryptionException(string message) : base(message) { }
/// <summary>Creates the exception, preserving the underlying cryptographic fault.</summary>
/// <param name="message">The failure description.</param>
/// <param name="innerException">The original cryptographic exception that caused the failure.</param>
public SecretDecryptionException(string message, Exception innerException) : base(message, innerException) { }
}
/// <summary>Thrown when a requested secret does not exist (or is tombstoned) in the store.</summary>
public sealed class SecretNotFoundException(string message) : InvalidOperationException(message);
/// <summary>Thrown when the secret store cannot be migrated to the supported schema.</summary>
public sealed class SecretStoreMigrationException(string message) : InvalidOperationException(message);
@@ -0,0 +1,61 @@
namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>
/// The encrypted, at-rest representation of a secret — one database row. This type
/// <b>never</b> holds plaintext: the secret material lives only in <see cref="Ciphertext"/>,
/// sealed under AES-256-GCM with a per-secret data-encryption key (DEK) that is itself
/// wrapped by the key-encryption key (KEK) identified by <see cref="KekId"/>.
/// </summary>
public record StoredSecret
{
/// <summary>The normalized secret name (primary identity).</summary>
public required SecretName Name { get; init; }
/// <summary>Optional human-readable description shown in management surfaces.</summary>
public string? Description { get; init; }
/// <summary>How the decrypted plaintext should be interpreted.</summary>
public required SecretContentType ContentType { get; init; }
/// <summary>The AES-256-GCM ciphertext of the secret plaintext.</summary>
public required byte[] Ciphertext { get; init; }
/// <summary>The AES-256-GCM nonce used to seal <see cref="Ciphertext"/>.</summary>
public required byte[] Nonce { get; init; }
/// <summary>The AES-256-GCM authentication tag for <see cref="Ciphertext"/>.</summary>
public required byte[] Tag { get; init; }
/// <summary>The per-secret data-encryption key (DEK), wrapped by the KEK.</summary>
public required byte[] WrappedDek { get; init; }
/// <summary>The AES-256-GCM nonce used to wrap <see cref="WrappedDek"/>.</summary>
public required byte[] WrapNonce { get; init; }
/// <summary>The AES-256-GCM authentication tag for <see cref="WrappedDek"/>.</summary>
public required byte[] WrapTag { get; init; }
/// <summary>Identifier of the key-encryption key (KEK) that wrapped the DEK.</summary>
public required string KekId { get; init; }
/// <summary>Monotonic revision number, incremented on each update.</summary>
public required long Revision { get; init; }
/// <summary>Whether the secret is soft-deleted (tombstoned).</summary>
public bool IsDeleted { get; init; }
/// <summary>When the secret was soft-deleted, if it is deleted; otherwise <c>null</c>.</summary>
public DateTimeOffset? DeletedUtc { get; init; }
/// <summary>When the secret was first created (UTC).</summary>
public required DateTimeOffset CreatedUtc { get; init; }
/// <summary>When the secret was last updated (UTC).</summary>
public required DateTimeOffset UpdatedUtc { get; init; }
/// <summary>Principal that created the secret, if known.</summary>
public string? CreatedBy { get; init; }
/// <summary>Principal that last updated the secret, if known.</summary>
public string? UpdatedBy { get; init; }
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<IsPackable>true</IsPackable>
<PackageId>ZB.MOM.WW.Secrets.Abstractions</PackageId>
<Authors>ZB.MOM.WW</Authors>
<Description>Secrets contracts (envelope-encrypted secret storage) for the ZB.MOM.WW SCADA family.</Description>
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-secrets</PackageProjectUrl>
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-secrets</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.Secrets.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,152 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Cli;
using ZB.MOM.WW.Secrets.DependencyInjection;
using ZB.MOM.WW.Secrets.Sqlite;
// Headless `secret` CLI: set / get / list / rm / rotate over the ZB.MOM.WW secrets store.
// The heavy lifting lives in SecretCommands; Program only builds the host, runs the schema
// migration, parses the verb + positional args, and dispatches.
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Configuration
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables();
builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
using IHost host = builder.Build();
// Ensure the schema exists before any store operation.
await host.Services.GetRequiredService<SqliteSecretsStoreMigrator>()
.MigrateAsync(CancellationToken.None);
var commands = new SecretCommands(
host.Services.GetRequiredService<ISecretStore>(),
host.Services.GetRequiredService<ISecretCipher>(),
host.Services.GetRequiredService<ISecretResolver>(),
Console.Out);
string actor = string.IsNullOrWhiteSpace(Environment.UserName) ? "cli" : Environment.UserName;
CancellationToken ct = CancellationToken.None;
if (args.Length == 0)
{
return Usage();
}
try
{
switch (args[0])
{
case "set":
{
if (args.Length < 3)
return Usage("set requires <name> and <value>.");
(SecretContentType contentType, string? description) = ParseSetOptions(args, startIndex: 3);
return await commands.SetAsync(args[1], args[2], contentType, description, actor, ct);
}
case "rotate":
{
if (args.Length < 3)
return Usage("rotate requires <name> and <value>.");
(SecretContentType contentType, string? description) = ParseSetOptions(args, startIndex: 3);
return await commands.RotateAsync(args[1], args[2], contentType, description, actor, ct);
}
case "get":
{
if (args.Length < 2)
return Usage("get requires <name>.");
return await commands.GetAsync(args[1], ct);
}
case "list":
{
bool includeDeleted = args.Skip(1).Any(a =>
string.Equals(a, "--include-deleted", StringComparison.Ordinal));
return await commands.ListAsync(includeDeleted, ct);
}
case "rm":
{
if (args.Length < 2)
return Usage("rm requires <name>.");
return await commands.RemoveAsync(args[1], actor, ct);
}
default:
return Usage($"Unknown command '{args[0]}'.");
}
}
catch (ArgumentException ex)
{
// Invalid secret name, bad --content-type value, unknown option: report without a stack trace.
Console.Error.WriteLine(ex.Message);
return 2;
}
// --- helpers ---------------------------------------------------------------
// Parses the optional flags shared by `set` and `rotate`:
// [--content-type text|connection-string|json|binary-base64] [--description "..."]
static (SecretContentType contentType, string? description) ParseSetOptions(string[] args, int startIndex)
{
SecretContentType contentType = SecretContentType.Text;
string? description = null;
for (int i = startIndex; i < args.Length; i++)
{
switch (args[i])
{
case "--content-type":
if (i + 1 >= args.Length)
throw new ArgumentException("--content-type requires a value.");
contentType = ParseContentType(args[++i]);
break;
case "--description":
if (i + 1 >= args.Length)
throw new ArgumentException("--description requires a value.");
description = args[++i];
break;
default:
throw new ArgumentException($"Unknown option '{args[i]}'.");
}
}
return (contentType, description);
}
// Maps the CLI content-type token (kebab-case) to the enum.
static SecretContentType ParseContentType(string value) => value switch
{
"text" => SecretContentType.Text,
"connection-string" => SecretContentType.ConnectionString,
"json" => SecretContentType.Json,
"binary-base64" => SecretContentType.BinaryBase64,
_ => throw new ArgumentException(
$"Unknown --content-type '{value}'; expected text|connection-string|json|binary-base64."),
};
// Prints usage (optionally preceded by an error line) and returns the standard usage exit code.
int Usage(string? error = null)
{
if (error is not null)
Console.Error.WriteLine(error);
Console.Error.WriteLine(
"""
Usage: secret <command> [args]
set <name> <value> [--content-type text|connection-string|json|binary-base64] [--description "..."]
rotate <name> <value> [--content-type ...] [--description "..."]
get <name>
list [--include-deleted]
rm <name>
""");
return 2;
}
@@ -0,0 +1,174 @@
using System.Text.Json;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Cli;
/// <summary>
/// The thin, front-end-agnostic command layer behind the <c>secret</c> CLI verbs
/// (<c>set</c> / <c>get</c> / <c>list</c> / <c>rm</c> / <c>rotate</c>). Each verb is an
/// <see cref="int"/>-returning task (0 on success, non-zero on error) and writes ALL output to the
/// injected <see cref="TextWriter"/> — never to <see cref="Console"/> directly — so the layer is
/// fully testable and can be hosted under any front-end. Confirmation and listing output is JSON
/// metadata only and NEVER carries a secret value; the sole value-exposing surface is
/// <see cref="GetAsync"/>, which intentionally prints the decrypted plaintext (see its remarks).
/// </summary>
public sealed class SecretCommands
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = false };
private readonly ISecretStore _store;
private readonly ISecretCipher _cipher;
private readonly ISecretResolver _resolver;
private readonly TextWriter _output;
/// <summary>Creates the command set over the supplied core seams and output sink.</summary>
/// <param name="store">The encrypted store (upsert / delete / list).</param>
/// <param name="cipher">The envelope cipher used to seal values on set / rotate.</param>
/// <param name="resolver">The read seam used by <c>get</c> to resolve current plaintext.</param>
/// <param name="output">Sink for all command output (JSON confirmations, listings, values).</param>
public SecretCommands(ISecretStore store, ISecretCipher cipher, ISecretResolver resolver, TextWriter output)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_cipher = cipher ?? throw new ArgumentNullException(nameof(cipher));
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
_output = output ?? throw new ArgumentNullException(nameof(output));
}
/// <summary>
/// set: seals <paramref name="value"/> under a fresh per-secret DEK and upserts it (create or
/// overwrite-in-place). Prints a JSON confirmation carrying only the name and action — never the
/// value.
/// </summary>
/// <param name="name">The secret name.</param>
/// <param name="value">The plaintext to seal.</param>
/// <param name="contentType">How consumers should interpret the plaintext.</param>
/// <param name="description">Optional human-readable description.</param>
/// <param name="actor">Principal recorded as created_by / updated_by, if known.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>0 on success.</returns>
public async Task<int> SetAsync(
string name, string value, SecretContentType contentType, string? description, string? actor, CancellationToken ct)
{
var secretName = new SecretName(name);
await UpsertSealedAsync(secretName, value, contentType, description, actor, ct).ConfigureAwait(false);
WriteJson(new { name = secretName.Value, action = "set" });
return 0;
}
/// <summary>
/// rotate: like <see cref="SetAsync"/>, but REQUIRES the secret to already exist (and not be
/// tombstoned). Rotating a missing secret prints a not-found error and returns non-zero without
/// creating anything. The confirmation carries only the name and action — never the value.
/// </summary>
/// <param name="name">The existing secret to rotate.</param>
/// <param name="value">The new plaintext to seal in place.</param>
/// <param name="contentType">How consumers should interpret the plaintext.</param>
/// <param name="description">Optional human-readable description.</param>
/// <param name="actor">Principal recorded as updated_by, if known.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>0 on success; non-zero if the secret does not exist.</returns>
public async Task<int> RotateAsync(
string name, string value, SecretContentType contentType, string? description, string? actor, CancellationToken ct)
{
var secretName = new SecretName(name);
StoredSecret? existing = await _store.GetAsync(secretName, ct).ConfigureAwait(false);
if (existing is null || existing.IsDeleted)
{
WriteJson(new { name = secretName.Value, error = "not-found" });
return 1;
}
await UpsertSealedAsync(secretName, value, contentType, description, actor, ct).ConfigureAwait(false);
WriteJson(new { name = secretName.Value, action = "rotate" });
return 0;
}
/// <summary>
/// get: resolves and prints the current DECRYPTED plaintext for <paramref name="name"/> to the
/// output sink.
/// </summary>
/// <remarks>
/// <b>This deliberately exposes the secret value.</b> It is the one command whose whole purpose
/// is to surface plaintext (to stdout), so an operator can pipe or capture it. Every other
/// command emits metadata only. A missing or tombstoned secret prints a JSON <c>not-found</c>
/// error and returns non-zero.
/// </remarks>
/// <param name="name">The secret to resolve.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>0 when the value was printed; non-zero when the secret is absent.</returns>
public async Task<int> GetAsync(string name, CancellationToken ct)
{
var secretName = new SecretName(name);
string? value = await _resolver.GetAsync(secretName, ct).ConfigureAwait(false);
if (value is null)
{
WriteJson(new { error = "not-found" });
return 1;
}
_output.WriteLine(value);
return 0;
}
/// <summary>
/// list: prints a JSON array of the safe metadata projection for every secret — name,
/// description, content type, KEK id, revision, tombstone flag, and update stamps. NEVER a value.
/// </summary>
/// <param name="includeDeleted">When <see langword="true"/>, tombstoned rows are included.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>0 on success.</returns>
public async Task<int> ListAsync(bool includeDeleted, CancellationToken ct)
{
IReadOnlyList<SecretMetadata> items = await _store.ListAsync(includeDeleted, ct).ConfigureAwait(false);
var projection = items.Select(m => new
{
name = m.Name.Value,
description = m.Description,
contentType = m.ContentType.ToString(),
kekId = m.KekId,
revision = m.Revision,
isDeleted = m.IsDeleted,
updatedUtc = m.UpdatedUtc,
updatedBy = m.UpdatedBy,
});
WriteJson(projection);
return 0;
}
/// <summary>
/// rm: soft-deletes (tombstones) <paramref name="name"/> and prints a JSON confirmation with a
/// <c>found</c> flag.
/// </summary>
/// <param name="name">The secret to tombstone.</param>
/// <param name="actor">Principal recorded as updated_by, if known.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>0 if a row was tombstoned; non-zero if no such row existed.</returns>
public async Task<int> RemoveAsync(string name, string? actor, CancellationToken ct)
{
var secretName = new SecretName(name);
bool removed = await _store.DeleteAsync(secretName, actor, ct).ConfigureAwait(false);
WriteJson(new { name = secretName.Value, action = "removed", found = removed });
return removed ? 0 : 1;
}
/// <summary>Seals a value under the cipher and upserts it, carrying description + actor stamps.</summary>
private async Task UpsertSealedAsync(
SecretName name, string value, SecretContentType contentType, string? description, string? actor, CancellationToken ct)
{
StoredSecret row = _cipher.Encrypt(name, value, contentType) with
{
Description = description,
CreatedBy = actor,
UpdatedBy = actor,
};
await _store.UpsertAsync(row, ct).ConfigureAwait(false);
}
/// <summary>Serializes <paramref name="value"/> to compact JSON on its own line.</summary>
private void WriteJson(object value) => _output.WriteLine(JsonSerializer.Serialize(value, JsonOptions));
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.Secrets\ZB.MOM.WW.Secrets.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,10 @@
{
"Secrets": {
"SqlitePath": "secrets.db",
"MasterKey": {
"Source": "Environment",
"EnvVarName": "ZB_SECRETS_MASTER_KEY"
},
"RunMigrationsOnStartup": true
}
}
@@ -0,0 +1,140 @@
@namespace ZB.MOM.WW.Secrets.Ui.Components
@implements IDisposable
@inject ISecretStore Store
@inject IAuditWriter Audit
@inject IServiceProvider Services
@* Components/ConfirmDeleteModal.razor — an in-page Blazor overlay (NOT a JS confirm/alert dialog).
Shown while Visible is true; Confirm tombstones + audits the delete, Cancel dismisses. *@
@if (Visible)
{
<div class="modal-backdrop-inpage" data-testid="delete-modal-backdrop"></div>
<div class="modal" role="dialog" aria-modal="true" data-testid="delete-modal">
<div class="modal-card">
<h3 class="modal-title">Delete secret '@Name.Value'?</h3>
<p>This tombstones the secret so the removal can propagate. The action is audited.</p>
@if (_error is not null)
{
<p class="field-error s-bad" data-testid="delete-error">@_error</p>
}
<div class="btn-row">
<TechButton Variant="ButtonVariant.Danger" Busy="_busy"
data-testid="confirm-delete" @onclick="ConfirmAsync">
Delete
</TechButton>
<TechButton Variant="ButtonVariant.Secondary"
data-testid="cancel-delete" @onclick="CancelAsync">
Cancel
</TechButton>
</div>
</div>
</div>
}
@code {
private readonly CancellationTokenSource _cts = new();
private string? _error;
private bool _busy;
/// <summary>Whether the modal overlay is rendered.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>The secret the modal offers to delete.</summary>
[Parameter] public SecretName Name { get; set; }
/// <summary>Raised after the secret has been tombstoned and audited.</summary>
[Parameter] public EventCallback OnDeleted { get; set; }
/// <summary>Raised when the user cancels the deletion.</summary>
[Parameter] public EventCallback OnCancelled { get; set; }
/// <summary>The cascading authentication state, used as the fallback source of the audit actor.</summary>
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
private async Task ConfirmAsync()
{
_error = null;
_busy = true;
bool deleted = false;
try
{
string actor = await SecretActorResolver.ResolveAsync(Services, AuthState);
try
{
await Store.DeleteAsync(Name, actor, _cts.Token);
await Audit.WriteAsync(
new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = actor,
Action = "secret.delete",
Outcome = AuditOutcome.Success,
Category = "Secrets",
Target = Name.Value,
},
_cts.Token);
deleted = true;
}
catch (Exception)
{
// An unexpected store fault must still be audited as a Failure and surfaced to the
// operator instead of tearing down the circuit.
_error = "Unable to delete the secret.";
await WriteFailureAuditAsync(actor);
}
}
finally
{
_busy = false;
}
if (deleted)
{
// Evict any cached plaintext so the ${secret:} / ISecretResolver path does not serve a
// stale value after this delete for the remainder of its TTL. Optional seam — a no-op
// when the host has not registered an invalidator.
Services.GetService<ISecretCacheInvalidator>()?.Invalidate(Name);
await OnDeleted.InvokeAsync();
}
}
private async Task WriteFailureAuditAsync(string actor)
{
try
{
await Audit.WriteAsync(
new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = actor,
Action = "secret.delete",
Outcome = AuditOutcome.Failure,
Category = "Secrets",
Target = Name.Value,
DetailsJson = "{\"reason\":\"error\"}",
},
_cts.Token);
}
catch
{
// Best-effort audit of a failure; never let audit itself surface into the event pipeline.
}
}
private Task CancelAsync() => OnCancelled.InvokeAsync();
/// <inheritdoc />
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
}
@@ -0,0 +1,127 @@
@namespace ZB.MOM.WW.Secrets.Ui.Components
@implements IDisposable
@inject ISecretStore Store
@inject ISecretCipher Cipher
@inject IAuditWriter Audit
@inject IServiceProvider Services
@* Components/RevealButton.razor — a policy-gated, audited reveal. The plaintext is loaded and
shown only AFTER the click, straight from the store+cipher (a human reveal shows the CURRENT
value, distinct from any cached machine resolve). The value never enters an audit field. *@
@if (!_revealed)
{
<button type="button" class="btn btn-outline-secondary reveal-secret"
disabled="@_busy" data-secret-name="@Name.Value"
data-testid="reveal-button" @onclick="RevealAsync">
Reveal
</button>
}
else
{
<div class="reveal-panel" data-testid="reveal-panel">
@if (_value is not null)
{
<code class="revealed-value" data-testid="revealed-value">@_value</code>
}
else
{
<span class="field-error s-bad" data-testid="reveal-error">@(_error ?? "Unavailable.")</span>
}
<button type="button" class="btn btn-link hide-secret"
data-testid="hide-button" @onclick="Hide">
Hide
</button>
</div>
}
@code {
private readonly CancellationTokenSource _cts = new();
private bool _revealed;
private string? _value;
private string? _error;
private bool _busy;
/// <summary>The secret whose plaintext this button reveals.</summary>
[Parameter] public SecretName Name { get; set; }
/// <summary>The cascading authentication state, used as the fallback source of the audit actor.</summary>
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
private async Task RevealAsync()
{
_busy = true;
AuditOutcome outcome;
try
{
StoredSecret? row = await Store.GetAsync(Name, _cts.Token);
if (row is null)
{
_value = null;
_error = "Secret not found.";
outcome = AuditOutcome.Failure;
}
else
{
_value = Cipher.Decrypt(row);
_error = null;
outcome = AuditOutcome.Success;
}
}
catch (SecretDecryptionException)
{
_value = null;
_error = "Unable to decrypt secret.";
outcome = AuditOutcome.Failure;
}
catch (Exception)
{
// Any other unexpected store/cipher fault (e.g. master key unavailable, timeout) must
// still be audited as a Failure and surfaced to the user, not left to tear down the
// circuit. The value is never shown.
_value = null;
_error = "Unable to reveal secret.";
outcome = AuditOutcome.Failure;
}
finally
{
_busy = false;
_revealed = true;
}
string actor = await SecretActorResolver.ResolveAsync(Services, AuthState);
try
{
await Audit.WriteAsync(
new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = actor,
Action = "secret.reveal",
Outcome = outcome,
Category = "Secrets",
Target = Name.Value,
DetailsJson = outcome == AuditOutcome.Failure ? "{\"reason\":\"error\"}" : null,
},
_cts.Token);
}
catch
{
// Best-effort audit; never let an audit-write fault surface into the event pipeline.
}
}
private void Hide()
{
_revealed = false;
_value = null;
_error = null;
}
/// <inheritdoc />
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
}
@@ -0,0 +1,213 @@
@namespace ZB.MOM.WW.Secrets.Ui.Components
@implements IDisposable
@inject ISecretCipher Cipher
@inject ISecretStore Store
@inject IAuditWriter Audit
@inject IServiceProvider Services
@* Components/SecretEditor.razor — add (ExistingName == null) or rotate (ExistingName set) a secret.
The value is entered through a masked input and never rendered back or placed in any audit field. *@
<TechCard Title="@(IsRotate ? "Rotate secret" : "Add secret")">
<TechField Label="Name">
@if (IsRotate)
{
<input class="form-control mono" type="text" readonly
value="@_name" data-testid="secret-name" />
}
else
{
<input class="form-control mono" type="text"
@bind="_name" data-testid="secret-name" />
}
</TechField>
<TechField Label="Description">
<input class="form-control" type="text" @bind="_description" data-testid="secret-description" />
</TechField>
<TechField Label="Content type">
<select class="form-select" @bind="_contentType" data-testid="secret-content-type">
@foreach (SecretContentType ct in Enum.GetValues<SecretContentType>())
{
<option value="@ct">@ct</option>
}
</select>
</TechField>
<TechField Label="Value">
<input class="form-control mono" type="password" autocomplete="new-password"
@bind="_value" data-testid="secret-value" />
</TechField>
@if (_error is not null)
{
<p class="field-error s-bad" data-testid="editor-error">@_error</p>
}
<div class="btn-row">
<TechButton Variant="ButtonVariant.Primary" Busy="_busy"
data-testid="editor-submit" @onclick="SubmitAsync">
@(IsRotate ? "Rotate" : "Add")
</TechButton>
<TechButton Variant="ButtonVariant.Secondary"
data-testid="editor-cancel" @onclick="CancelAsync">
Cancel
</TechButton>
</div>
</TechCard>
@code {
private readonly CancellationTokenSource _cts = new();
private string _name = string.Empty;
private string _description = string.Empty;
private SecretContentType _contentType = SecretContentType.Text;
private string _value = string.Empty;
private string? _error;
private bool _busy;
/// <summary>
/// The secret being rotated. When <c>null</c> the editor is in <em>add</em> mode (name is
/// editable); when set, the editor is in <em>rotate</em> mode (name is read-only and fixed).
/// </summary>
[Parameter] public SecretName? ExistingName { get; set; }
/// <summary>Raised after a successful add or rotate, so a parent can refresh its listing.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
/// <summary>Raised when the user cancels the editor without saving.</summary>
[Parameter] public EventCallback OnCancelled { get; set; }
/// <summary>The cascading authentication state, used as the fallback source of the audit actor.</summary>
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
private bool IsRotate => ExistingName is not null;
private bool _metadataLoaded;
/// <inheritdoc />
protected override async Task OnParametersSetAsync()
{
if (IsRotate)
{
_name = ExistingName!.Value.Value;
// Rotate must preserve the existing row's metadata: the store's upsert overwrites the
// description and content type from the incoming row, so pre-fill both from the current
// row (metadata only — no plaintext is read or decrypted). Load once so a re-render does
// not clobber operator edits. The operator still supplies the NEW value.
if (!_metadataLoaded)
{
_metadataLoaded = true;
StoredSecret? row = await Store.GetAsync(ExistingName.Value, _cts.Token);
_description = row?.Description ?? string.Empty;
_contentType = row?.ContentType ?? SecretContentType.Text;
}
}
}
private async Task SubmitAsync()
{
_error = null;
SecretName name;
try
{
name = ExistingName ?? new SecretName(_name);
}
catch (ArgumentException ex)
{
_error = ex.Message;
return;
}
_busy = true;
bool saved = false;
try
{
string actor = await SecretActorResolver.ResolveAsync(Services, AuthState);
try
{
StoredSecret row = Cipher.Encrypt(name, _value, _contentType);
row = row with
{
Description = string.IsNullOrWhiteSpace(_description) ? null : _description,
CreatedBy = actor,
UpdatedBy = actor,
};
await Store.UpsertAsync(row, _cts.Token);
await Audit.WriteAsync(
new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = actor,
Action = IsRotate ? "secret.rotate" : "secret.add",
Outcome = AuditOutcome.Success,
Category = "Secrets",
Target = name.Value,
},
_cts.Token);
saved = true;
}
catch (Exception)
{
// An unexpected store/cipher fault (e.g. master key unavailable, timeout) must still
// be audited as a Failure and surfaced to the operator — never the value — instead of
// tearing down the circuit. The detail carries no plaintext.
_error = IsRotate ? "Unable to rotate the secret." : "Unable to add the secret.";
await WriteFailureAuditAsync(
actor, IsRotate ? "secret.rotate" : "secret.add", name.Value);
}
}
finally
{
_busy = false;
}
if (saved)
{
// Evict any cached plaintext so the ${secret:} / ISecretResolver path does not serve a
// stale value after this rotate/add for the remainder of its TTL. Optional seam — a no-op
// when the host has not registered an invalidator.
Services.GetService<ISecretCacheInvalidator>()?.Invalidate(name);
await OnSaved.InvokeAsync();
}
}
private async Task WriteFailureAuditAsync(string actor, string action, string target)
{
try
{
await Audit.WriteAsync(
new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = actor,
Action = action,
Outcome = AuditOutcome.Failure,
Category = "Secrets",
Target = target,
DetailsJson = "{\"reason\":\"error\"}",
},
_cts.Token);
}
catch
{
// Best-effort audit of a failure; never let audit itself surface into the event pipeline.
}
}
private Task CancelAsync() => OnCancelled.InvokeAsync();
/// <inheritdoc />
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
}
@@ -0,0 +1,150 @@
@namespace ZB.MOM.WW.Secrets.Ui
@implements IDisposable
@inject ISecretStore Store
@* Components/SecretsList.razor — metadata-only listing of secrets. Never renders a value or
ciphertext. Add/rotate use the in-page SecretEditor; delete uses the in-page ConfirmDeleteModal;
reveal is the policy-gated, audited RevealButton. The list reloads after any mutation. *@
<AuthorizeView Policy="@SecretsAuthorization.ManagePolicy">
<div class="toolbar btn-row">
<TechButton Variant="ButtonVariant.Primary" data-testid="add-secret" @onclick="ShowAdd">
Add secret
</TechButton>
</div>
</AuthorizeView>
@if (_editorMode != EditorMode.None)
{
<SecretEditor ExistingName="_rotateName"
OnSaved="OnMutatedAsync"
OnCancelled="CloseEditor" />
}
<TechCard Title="Secrets">
@if (_secrets is null)
{
<p class="mono">Loading…</p>
}
else
{
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Content type</th>
<th>KEK</th>
<th class="num">Revision</th>
<th>Updated (UTC)</th>
<th>Updated by</th>
<th></th>
</tr>
</thead>
<tbody>
@if (_secrets.Count == 0)
{
<tr>
<td class="empty-row" colspan="8">No secrets.</td>
</tr>
}
else
{
@foreach (SecretMetadata secret in _secrets)
{
<tr>
<td class="mono">@secret.Name.Value</td>
<td>@secret.Description</td>
<td>@secret.ContentType</td>
<td class="mono">@secret.KekId</td>
<td class="num">@secret.Revision</td>
<td class="mono">@secret.UpdatedUtc.UtcDateTime.ToString("u")</td>
<td>@secret.UpdatedBy</td>
<td class="row-actions">
<AuthorizeView Policy="@SecretsAuthorization.RevealPolicy">
<RevealButton Name="secret.Name" />
</AuthorizeView>
<AuthorizeView Policy="@SecretsAuthorization.ManagePolicy">
<button type="button" class="btn btn-outline-secondary rotate-secret"
data-secret-name="@secret.Name.Value"
@onclick="() => ShowRotate(secret.Name)">
Rotate
</button>
<button type="button" class="btn btn-danger delete-secret"
data-secret-name="@secret.Name.Value"
@onclick="() => ShowDelete(secret.Name)">
Delete
</button>
</AuthorizeView>
</td>
</tr>
}
}
</tbody>
</table>
</div>
}
</TechCard>
<ConfirmDeleteModal Visible="_deleteTarget.HasValue"
Name="_deleteTarget.GetValueOrDefault()"
OnDeleted="OnMutatedAsync"
OnCancelled="CloseDelete" />
@code {
private readonly CancellationTokenSource _cts = new();
private IReadOnlyList<SecretMetadata>? _secrets;
private EditorMode _editorMode = EditorMode.None;
private SecretName? _rotateName;
private SecretName? _deleteTarget;
private enum EditorMode
{
None,
Add,
Rotate,
}
/// <inheritdoc />
protected override async Task OnInitializedAsync() => await ReloadAsync();
private async Task ReloadAsync()
=> _secrets = await Store.ListAsync(includeDeleted: false, _cts.Token);
private void ShowAdd()
{
_rotateName = null;
_editorMode = EditorMode.Add;
}
private void ShowRotate(SecretName name)
{
_rotateName = name;
_editorMode = EditorMode.Rotate;
}
private void CloseEditor()
{
_editorMode = EditorMode.None;
_rotateName = null;
}
private void ShowDelete(SecretName name) => _deleteTarget = name;
private void CloseDelete() => _deleteTarget = null;
private async Task OnMutatedAsync()
{
CloseEditor();
CloseDelete();
await ReloadAsync();
}
/// <inheritdoc />
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
}
@@ -0,0 +1,9 @@
@page "/admin/secrets"
@namespace ZB.MOM.WW.Secrets.Ui
@attribute [Authorize(Policy = SecretsAuthorization.ManagePolicy)]
@* Pages/SecretsPage.razor — the host supplies the outer Theme shell/layout. *@
<h1>Secrets</h1>
<p>Manage stored secrets. Values are never shown here — reveal is a separate, more privileged action.</p>
<SecretsList />
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Ui;
/// <summary>
/// Resolves the principal to record as the audit <c>Actor</c> for a UI-driven secret mutation or
/// reveal. The resolution order is: an explicitly wired <see cref="ISecretActorAccessor"/> (if the
/// host registered one and it reports a non-blank actor); otherwise the authenticated user's
/// <see cref="System.Security.Principal.IIdentity.Name"/> from the cascading authentication state;
/// otherwise the literal <c>"unknown"</c> so an action is never attributed to a blank actor.
/// </summary>
internal static class SecretActorResolver
{
/// <summary>The fallback actor used when no accessor and no authenticated identity name is available.</summary>
public const string Unknown = "unknown";
/// <summary>
/// Resolves the audit actor. Never returns <c>null</c> or blank.
/// </summary>
/// <param name="services">The component's service provider, used to optionally resolve an <see cref="ISecretActorAccessor"/>.</param>
/// <param name="authState">The cascading authentication-state task, or <c>null</c> if unavailable.</param>
/// <returns>The resolved actor identity string.</returns>
public static async Task<string> ResolveAsync(IServiceProvider services, Task<AuthenticationState>? authState)
{
string? fromAccessor = services.GetService<ISecretActorAccessor>()?.CurrentActor;
if (!string.IsNullOrWhiteSpace(fromAccessor))
{
return fromAccessor;
}
if (authState is not null)
{
AuthenticationState state = await authState.ConfigureAwait(false);
string? name = state.User.Identity?.Name;
if (!string.IsNullOrWhiteSpace(name))
{
return name;
}
}
return Unknown;
}
}
@@ -0,0 +1,80 @@
using Microsoft.AspNetCore.Authorization;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.Auth.AspNetCore;
namespace ZB.MOM.WW.Secrets.Ui;
/// <summary>
/// Authorization policies and the roles that back them for the secrets-management UI.
/// </summary>
/// <remarks>
/// Two policies gate the UI, layered by privilege:
/// <list type="bullet">
/// <item>
/// <description>
/// <see cref="ManagePolicy"/> — view and administer secret <em>metadata</em> (the list
/// page, create/rename/delete affordances). Satisfied by <see cref="ManageRole"/>,
/// <see cref="RevealRole"/>, or <see cref="AdminRole"/>.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="RevealPolicy"/> — the strictly more privileged right to reveal a secret's
/// plaintext value. Satisfied only by <see cref="RevealRole"/> or <see cref="AdminRole"/>.
/// Because both reveal-granting roles also satisfy <see cref="ManagePolicy"/>, holding
/// reveal always implies manage.
/// </description>
/// </item>
/// </list>
/// Both policies require an authenticated user. The requirements are expressed as role checks
/// against the canonical <see cref="ZbClaimTypes.Role"/> claim, so the host maps its LDAP
/// groups to these role names (via its <c>IGroupRoleMapper</c> / <c>Security:Ldap:GroupToRole</c>
/// configuration) exactly as it does for every other <c>ZB.MOM.WW</c> role. The role-name
/// constants are deliberately minimal and can be overridden by the host by registering its own
/// policies under the same policy names instead of calling <see cref="AddSecretsAuthorization"/>.
/// </remarks>
public static class SecretsAuthorization
{
/// <summary>Policy name for administering secret metadata (the list page and management actions).</summary>
public const string ManagePolicy = "secrets:manage";
/// <summary>Policy name for revealing a secret's plaintext value — strictly more privileged than <see cref="ManagePolicy"/>.</summary>
public const string RevealPolicy = "secrets:reveal";
/// <summary>Role that grants secret metadata management (satisfies <see cref="ManagePolicy"/>).</summary>
public const string ManageRole = "secrets-manager";
/// <summary>Role that grants secret value reveal (satisfies both <see cref="RevealPolicy"/> and <see cref="ManagePolicy"/>).</summary>
public const string RevealRole = "secrets-reveal";
/// <summary>
/// Administrator role that satisfies every secrets policy. Uses the family's canonical
/// <see cref="CanonicalRole.Administrator"/> role name ("Administrator") so an existing
/// admin principal satisfies the secrets policies without a separate mapping. Role-claim
/// values are compared case-sensitively, so the exact canonical spelling matters.
/// </summary>
public const string AdminRole = nameof(CanonicalRole.Administrator);
/// <summary>
/// Registers the <see cref="ManagePolicy"/> and <see cref="RevealPolicy"/> policies on the
/// supplied <see cref="AuthorizationOptions"/>. Each policy requires an authenticated user;
/// <see cref="RevealPolicy"/> is a strict superset-privilege of <see cref="ManagePolicy"/>.
/// </summary>
/// <param name="options">The authorization options to add the policies to.</param>
/// <returns>The same <paramref name="options"/> instance, to allow chaining.</returns>
/// <exception cref="ArgumentNullException"><paramref name="options"/> is <c>null</c>.</exception>
public static AuthorizationOptions AddSecretsAuthorization(this AuthorizationOptions options)
{
ArgumentNullException.ThrowIfNull(options);
options.AddPolicy(ManagePolicy, policy => policy
.RequireAuthenticatedUser()
.RequireRole(ManageRole, RevealRole, AdminRole));
options.AddPolicy(RevealPolicy, policy => policy
.RequireAuthenticatedUser()
.RequireRole(RevealRole, AdminRole));
return options;
}
}
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<RootNamespace>ZB.MOM.WW.Secrets.Ui</RootNamespace>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Emit the XML doc file so missing-doc (CS1591) warnings on the public API
surface under TreatWarningsAsErrors. Razor compiles each component to a
public class whose generated members carry no docs, so CS1591 is left
out of the error set (it would flag generated members, not authored ones)
while still producing the doc XML consumers see at the call site. -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<IsPackable>true</IsPackable>
<PackageId>ZB.MOM.WW.Secrets.Ui</PackageId>
<Authors>ZB.MOM.WW</Authors>
<Description>Blazor secrets-management UI (Technical-Light) for the ZB.MOM.WW SCADA family.</Description>
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-secrets</PackageProjectUrl>
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-secrets</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.Secrets.Abstractions\ZB.MOM.WW.Secrets.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="ZB.MOM.WW.Theme" />
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" />
<PackageReference Include="ZB.MOM.WW.Audit" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.Secrets.Ui.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,10 @@
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Authorization
@using Microsoft.Extensions.DependencyInjection
@using ZB.MOM.WW.Theme
@using ZB.MOM.WW.Audit
@using ZB.MOM.WW.Secrets.Abstractions
@using ZB.MOM.WW.Secrets.Ui
@using ZB.MOM.WW.Secrets.Ui.Components
@@ -0,0 +1,128 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Configuration;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Configuration;
/// <summary>
/// Expands <c>${secret:name}</c> reference tokens in configuration values by resolving each
/// referenced secret through an <see cref="ISecretResolver"/>. Expansion is <em>fail-closed</em>:
/// a token that names an absent secret throws <see cref="SecretNotFoundException"/> rather than
/// leaving the placeholder (or a blank) in place, so a misconfigured deployment fails loudly at
/// startup instead of silently running with an empty credential.
/// </summary>
public sealed class SecretReferenceExpander
{
private const string TokenMarker = "${secret:";
private static readonly Regex TokenPattern = new(@"\$\{secret:([^}]+)\}", RegexOptions.Compiled);
private readonly ISecretResolver _resolver;
/// <summary>Creates an expander that resolves references via <paramref name="resolver"/>.</summary>
/// <param name="resolver">The resolver used to fetch each referenced secret's plaintext.</param>
public SecretReferenceExpander(ISecretResolver resolver) =>
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
/// <summary>
/// Replaces every <c>${secret:name}</c> token in <paramref name="raw"/> with the resolved
/// plaintext of the named secret. Multiple tokens (including repeats of the same name) are all
/// expanded; each distinct name is resolved once.
/// </summary>
/// <param name="raw">The raw configuration value, possibly containing reference tokens.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>
/// The expanded value, or <paramref name="raw"/> unchanged when it is <c>null</c> or contains
/// no tokens.
/// </returns>
/// <exception cref="SecretNotFoundException">A referenced secret does not exist (fail-closed).</exception>
public async Task<string> ExpandAsync(string raw, CancellationToken ct)
{
if (raw is null)
{
return raw!;
}
MatchCollection matches = TokenPattern.Matches(raw);
if (matches.Count == 0)
{
return raw;
}
// Resolve each distinct referenced name exactly once, then substitute synchronously.
var resolved = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (Match match in matches)
{
string name = match.Groups[1].Value.Trim();
if (resolved.ContainsKey(name))
{
continue;
}
string? value = await _resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
if (value is null)
{
throw new SecretNotFoundException(
$"Configuration references unknown secret '{name}' via ${{secret:{name}}}.");
}
resolved[name] = value;
}
return TokenPattern.Replace(raw, m => resolved[m.Groups[1].Value.Trim()]);
}
/// <summary>
/// Walks every entry in <paramref name="config"/> and rewrites, in place, any value that
/// contains a <c>${secret:...}</c> token to its expanded plaintext. Writes go through the
/// <see cref="IConfigurationRoot"/> indexer so subsequent reads — and any options binding done
/// afterward — observe the expanded values.
/// </summary>
/// <remarks>
/// This MUST run <em>after</em> configuration is loaded and <em>before</em> options validation
/// or <c>ValidateOnStart</c> executes; otherwise validators may see the unexpanded placeholder.
/// Fail-closed: a missing referenced secret propagates <see cref="SecretNotFoundException"/> out
/// of startup.
/// <para>
/// Documentation/comment keys are skipped: any entry whose leaf key segment (the part after the
/// last <c>:</c>) begins with an underscore — the widely-used <c>"_comment"</c> JSON convention —
/// is left untouched. This lets an appsettings file document the <c>${secret:...}</c> syntax in a
/// <c>_comment</c> value (even with a literal example token) without that example being treated as
/// a real reference to resolve. Functional keys are never named with a leading underscore.
/// </para>
/// </remarks>
/// <param name="config">The loaded configuration root to mutate.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <exception cref="SecretNotFoundException">A referenced secret does not exist (fail-closed).</exception>
public async Task ExpandConfigurationAsync(IConfigurationRoot config, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(config);
// Materialize first: mutating provider data while enumerating AsEnumerable() is unsafe.
var pending = new List<KeyValuePair<string, string>>();
foreach (KeyValuePair<string, string?> entry in config.AsEnumerable())
{
if (entry.Value is { } value
&& value.Contains(TokenMarker, StringComparison.Ordinal)
&& !IsCommentKey(entry.Key))
{
pending.Add(new KeyValuePair<string, string>(entry.Key, value));
}
}
foreach (KeyValuePair<string, string> entry in pending)
{
config[entry.Key] = await ExpandAsync(entry.Value, ct).ConfigureAwait(false);
}
}
// A configuration key is a comment/documentation key when its leaf segment (after the last ':')
// starts with '_' — the conventional "_comment" marker. Such values may legitimately contain a
// literal ${secret:...} example and must not be resolved.
private static bool IsCommentKey(string key)
{
int lastSeparator = key.LastIndexOf(':');
string leaf = lastSeparator >= 0 ? key[(lastSeparator + 1)..] : key;
return leaf.StartsWith('_');
}
}
@@ -0,0 +1,156 @@
using System.Security.Cryptography;
using System.Text;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Crypto;
/// <summary>
/// The default <see cref="ISecretCipher"/>: AES-256-GCM envelope encryption.
/// </summary>
/// <remarks>
/// <para>
/// Each secret is sealed under a fresh, per-secret 32-byte data-encryption key (DEK). The
/// DEK encrypts the UTF-8 plaintext with AES-256-GCM (12-byte nonce, 16-byte tag), binding the
/// secret <see cref="SecretName.Value"/> as additional authenticated data (AAD) so ciphertext
/// cannot be silently relocated to a different name. The DEK is then <i>wrapped</i> with a second
/// AES-256-GCM operation under the master key (KEK) from an <see cref="IMasterKeyProvider"/>,
/// binding the provider's <see cref="IMasterKeyProvider.KekId"/> as AAD so a row can only be
/// unwrapped by the KEK that is claimed to have sealed it.
/// </para>
/// <para>
/// GCM's authentication tag provides constant-time integrity verification, so no additional
/// constant-time comparison is required. Every cryptographic failure path — a mismatched tag,
/// a wrong or unknown KEK, or any other <see cref="CryptographicException"/> — is surfaced as a
/// <see cref="SecretDecryptionException"/>; the cipher never returns a wrong plaintext and never
/// leaks a raw cryptographic exception. The transient DEK buffer is always zeroed with
/// <see cref="CryptographicOperations.ZeroMemory"/>.
/// </para>
/// </remarks>
public sealed class AesGcmEnvelopeCipher : ISecretCipher
{
private const int KeySizeBytes = 32; // AES-256
private const int NonceSizeBytes = 12; // AES-GCM standard nonce
private const int TagSizeBytes = 16; // AES-GCM full tag
private readonly IMasterKeyProvider _masterKeyProvider;
/// <summary>
/// Creates a cipher that wraps and unwraps DEKs with the master key supplied by
/// <paramref name="masterKeyProvider"/>.
/// </summary>
/// <param name="masterKeyProvider">The source of the AES-256 master key (KEK) and its id.</param>
public AesGcmEnvelopeCipher(IMasterKeyProvider masterKeyProvider)
{
_masterKeyProvider = masterKeyProvider ?? throw new ArgumentNullException(nameof(masterKeyProvider));
}
/// <inheritdoc />
public StoredSecret Encrypt(SecretName name, string plaintext, SecretContentType contentType)
{
ArgumentNullException.ThrowIfNull(plaintext);
string kekId = _masterKeyProvider.KekId;
ReadOnlySpan<byte> masterKey = _masterKeyProvider.GetMasterKey().Span;
byte[] dek = new byte[KeySizeBytes];
try
{
RandomNumberGenerator.Fill(dek);
// Layer 1: seal the plaintext under the DEK, binding the secret name as AAD.
byte[] plaintextBytes = Encoding.UTF8.GetBytes(plaintext);
byte[] nonce = new byte[NonceSizeBytes];
byte[] tag = new byte[TagSizeBytes];
byte[] ciphertext = new byte[plaintextBytes.Length];
byte[] nameAad = Encoding.UTF8.GetBytes(name.Value);
RandomNumberGenerator.Fill(nonce);
using (var bodyGcm = new AesGcm(dek, TagSizeBytes))
{
bodyGcm.Encrypt(nonce, plaintextBytes, ciphertext, tag, nameAad);
}
// Layer 2: wrap the DEK under the master key, binding the KEK id as AAD.
byte[] wrapNonce = new byte[NonceSizeBytes];
byte[] wrapTag = new byte[TagSizeBytes];
byte[] wrappedDek = new byte[KeySizeBytes];
byte[] kekAad = Encoding.UTF8.GetBytes(kekId);
RandomNumberGenerator.Fill(wrapNonce);
using (var wrapGcm = new AesGcm(masterKey, TagSizeBytes))
{
wrapGcm.Encrypt(wrapNonce, dek, wrappedDek, wrapTag, kekAad);
}
return new StoredSecret
{
Name = name,
ContentType = contentType,
Ciphertext = ciphertext,
Nonce = nonce,
Tag = tag,
WrappedDek = wrappedDek,
WrapNonce = wrapNonce,
WrapTag = wrapTag,
KekId = kekId,
Revision = 0,
IsDeleted = false,
CreatedUtc = default,
UpdatedUtc = default,
};
}
finally
{
CryptographicOperations.ZeroMemory(dek);
}
}
/// <inheritdoc />
public string Decrypt(StoredSecret secret)
{
ArgumentNullException.ThrowIfNull(secret);
string kekId = _masterKeyProvider.KekId;
if (!string.Equals(kekId, secret.KekId, StringComparison.Ordinal))
{
throw new SecretDecryptionException($"Row wrapped by unknown KEK '{secret.KekId}'.");
}
ReadOnlySpan<byte> masterKey = _masterKeyProvider.GetMasterKey().Span;
byte[] dek = new byte[KeySizeBytes];
try
{
// Unwrap the DEK under the master key, verifying the KEK-id AAD binding.
byte[] kekAad = Encoding.UTF8.GetBytes(secret.KekId);
using (var wrapGcm = new AesGcm(masterKey, TagSizeBytes))
{
wrapGcm.Decrypt(secret.WrapNonce, secret.WrappedDek, secret.WrapTag, dek, kekAad);
}
// Decrypt the body under the DEK, verifying the name AAD binding.
byte[] nameAad = Encoding.UTF8.GetBytes(secret.Name.Value);
byte[] plaintextBytes = new byte[secret.Ciphertext.Length];
using (var bodyGcm = new AesGcm(dek, TagSizeBytes))
{
bodyGcm.Decrypt(secret.Nonce, secret.Ciphertext, secret.Tag, plaintextBytes, nameAad);
}
return Encoding.UTF8.GetString(plaintextBytes);
}
catch (AuthenticationTagMismatchException ex)
{
throw new SecretDecryptionException(
$"Failed to decrypt secret '{secret.Name.Value}': authentication tag mismatch.", ex);
}
catch (CryptographicException ex)
{
throw new SecretDecryptionException(
$"Failed to decrypt secret '{secret.Name.Value}'.", ex);
}
finally
{
CryptographicOperations.ZeroMemory(dek);
}
}
}
@@ -0,0 +1,165 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets;
/// <summary>
/// The default <see cref="ISecretResolver"/>: loads an encrypted row from the store, decrypts it
/// on demand, and serves it from a short-lived in-memory cache. Every resolution — hit, miss,
/// tombstone, or decryption failure — emits an <see cref="AuditEvent"/> recording <b>which</b>
/// secret was accessed and the outcome. The plaintext value is <b>never</b> placed in any audit
/// field.
/// </summary>
/// <remarks>
/// <para>
/// <b>Security note — the cache holds decrypted secret material in process memory.</b> The TTL is
/// deliberately kept short (configured by the caller / DI) so plaintext lives only briefly and a
/// rotation or revocation is picked up quickly. <see cref="Invalidate"/> lets the write path evict
/// an entry immediately after a rotate or delete. The cache is keyed by the normalized
/// <see cref="SecretName.Value"/> and is safe for concurrent use.
/// </para>
/// <para>
/// A missing or tombstoned secret is <b>not</b> an exception: <see cref="GetAsync"/> returns
/// <c>null</c> and audits a <see cref="AuditOutcome.Failure"/> with a <c>not-found</c> action, so a
/// consumer can distinguish "absent" from "present" without a throw on the hot path.
/// </para>
/// <para>
/// A <b>decryption or integrity failure</b> (tampered ciphertext or the wrong KEK) is different: it
/// is <b>fail-loud</b>. <see cref="GetAsync"/> audits a <see cref="AuditOutcome.Failure"/> with a
/// <c>decryption-failed</c> action and then rethrows the <see cref="SecretDecryptionException"/> — it
/// is never masked as a benign miss or a <c>null</c>. The audit records only the secret name; the
/// ciphertext and any value are never included.
/// </para>
/// </remarks>
public sealed class DefaultSecretResolver : ISecretResolver, ISecretCacheInvalidator
{
private const string SystemActor = "system";
private const string AuditCategory = "Secrets";
private const string ResolveAction = "secret.resolve";
private const string NotFoundAction = "secret.resolve.not-found";
private const string DecryptionFailedAction = "secret.resolve.decryption-failed";
private readonly ISecretStore _store;
private readonly ISecretCipher _cipher;
private readonly IAuditWriter _audit;
private readonly TimeSpan _cacheTtl;
private readonly ISecretActorAccessor? _actorAccessor;
private readonly TimeProvider _timeProvider;
// Keyed by SecretName.Value. Holds DECRYPTED plaintext — kept only for the short TTL.
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new(StringComparer.Ordinal);
/// <summary>
/// Creates a resolver over the given store, cipher, and audit writer.
/// </summary>
/// <param name="store">The encrypted-row store to read from.</param>
/// <param name="cipher">The cipher used to decrypt a loaded row.</param>
/// <param name="audit">The audit sink; one event is written per resolve (never the value).</param>
/// <param name="cacheTtl">
/// How long a decrypted value is cached in memory. Keep this short — it is plaintext secret
/// material. A non-positive value effectively disables caching (every entry is already expired).
/// </param>
/// <param name="actorAccessor">
/// Optional accessor for the current principal to attribute the audit to; a <c>null</c> accessor
/// (or a <c>null</c> <see cref="ISecretActorAccessor.CurrentActor"/>) is recorded as
/// <c>"system"</c>.
/// </param>
/// <param name="timeProvider">The clock (defaults to <see cref="TimeProvider.System"/>).</param>
public DefaultSecretResolver(
ISecretStore store,
ISecretCipher cipher,
IAuditWriter audit,
TimeSpan cacheTtl,
ISecretActorAccessor? actorAccessor = null,
TimeProvider? timeProvider = null)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_cipher = cipher ?? throw new ArgumentNullException(nameof(cipher));
_audit = audit ?? throw new ArgumentNullException(nameof(audit));
_cacheTtl = cacheTtl;
_actorAccessor = actorAccessor;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public async Task<string?> GetAsync(SecretName name, CancellationToken ct)
{
string key = name.Value;
DateTimeOffset now = _timeProvider.GetUtcNow();
// 1) Serve from cache while the entry is still live.
if (_cache.TryGetValue(key, out CacheEntry cached) && now < cached.ExpiresUtc)
{
await AuditSuccessAsync(name, source: "cache", ct).ConfigureAwait(false);
return cached.Plaintext;
}
// 2) Load the encrypted row.
StoredSecret? row = await _store.GetAsync(name, ct).ConfigureAwait(false);
if (row is null || row.IsDeleted)
{
await AuditNotFoundAsync(name, ct).ConfigureAwait(false);
return null;
}
// Decrypt on demand and cache the plaintext for the short TTL. A decryption/integrity
// failure (tampered ciphertext or wrong KEK) MUST leave an audit trail and then propagate —
// it is never masked as a benign miss. The audit records only the name; never the value.
string plaintext;
try
{
plaintext = _cipher.Decrypt(row);
}
catch (SecretDecryptionException)
{
await AuditDecryptionFailedAsync(name, ct).ConfigureAwait(false);
throw;
}
_cache[key] = new CacheEntry(plaintext, now + _cacheTtl, row.Revision);
await AuditSuccessAsync(name, source: "store", ct).ConfigureAwait(false);
return plaintext;
}
/// <summary>
/// Evicts the cached plaintext for <paramref name="name"/>, if any. The admin / write path
/// calls this (via <see cref="ISecretCacheInvalidator"/>) immediately after a rotate or delete
/// so a stale value is not served for the remainder of its TTL. Cheap and idempotent.
/// </summary>
/// <param name="name">The secret whose cache entry to remove.</param>
public void Invalidate(SecretName name) => _cache.TryRemove(name.Value, out _);
private Task AuditSuccessAsync(SecretName name, string source, CancellationToken ct) =>
WriteAuditAsync(name, ResolveAction, AuditOutcome.Success, $"{{\"source\":\"{source}\"}}", ct);
private Task AuditNotFoundAsync(SecretName name, CancellationToken ct) =>
WriteAuditAsync(name, NotFoundAction, AuditOutcome.Failure, "{\"reason\":\"not-found\"}", ct);
private Task AuditDecryptionFailedAsync(SecretName name, CancellationToken ct) =>
WriteAuditAsync(
name, DecryptionFailedAction, AuditOutcome.Failure, "{\"reason\":\"decryption-failed\"}", ct);
private Task WriteAuditAsync(
SecretName name, string action, AuditOutcome outcome, string detailsJson, CancellationToken ct)
{
// NOTE: only the secret NAME and the outcome are recorded — never the decrypted value.
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = _timeProvider.GetUtcNow(),
Actor = _actorAccessor?.CurrentActor ?? SystemActor,
Action = action,
Outcome = outcome,
Category = AuditCategory,
Target = name.Value,
DetailsJson = detailsJson,
};
return _audit.WriteAsync(evt, ct);
}
/// <summary>A cached decrypted secret and its expiry (revision kept for future invalidation).</summary>
private readonly record struct CacheEntry(string Plaintext, DateTimeOffset ExpiresUtc, long Revision);
}
@@ -0,0 +1,14 @@
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.DependencyInjection;
/// <summary>
/// The single-node default <see cref="ISecretReplicator"/>: publishing a row is a no-op because
/// there are no peers to broadcast to. The deferred <c>ZB.MOM.WW.Secrets.Akka</c> package replaces
/// this registration with a real cluster replicator for clustered deployments.
/// </summary>
public sealed class NoOpSecretReplicator : ISecretReplicator
{
/// <inheritdoc />
public Task PublishAsync(StoredSecret row, CancellationToken ct) => Task.CompletedTask;
}
@@ -0,0 +1,27 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Secrets.Sqlite;
namespace ZB.MOM.WW.Secrets.DependencyInjection;
/// <summary>
/// Runs the secrets SQLite schema migration at application startup when
/// <see cref="SecretsOptions.RunMigrationsOnStartup"/> is <see langword="true"/>. The migration is
/// idempotent, so repeated restarts are safe.
/// </summary>
internal sealed class SecretsMigrationHostedService(
SqliteSecretsStoreMigrator migrator,
IOptions<SecretsOptions> options) : IHostedService
{
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
if (options.Value.RunMigrationsOnStartup)
{
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
}
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -0,0 +1,93 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Crypto;
using ZB.MOM.WW.Secrets.MasterKey;
using ZB.MOM.WW.Secrets.Sqlite;
namespace ZB.MOM.WW.Secrets.DependencyInjection;
/// <summary>
/// Dependency-injection helpers that wire the ZB.MOM.WW secrets subsystem — master-key provider,
/// envelope cipher, SQLite store, resolver, no-op replicator, and the startup migration hosted
/// service — from configuration with a single call.
/// </summary>
public static class SecretsServiceCollectionExtensions
{
/// <summary>
/// Registers the secrets subsystem: binds <see cref="SecretsOptions"/> from the configuration
/// section at <paramref name="sectionPath"/> and wires the master-key provider, envelope cipher,
/// SQLite-backed store, the caching resolver, the single-node no-op replicator, and a hosted
/// service that runs the schema migration on startup (when enabled).
/// </summary>
/// <param name="services">The service collection to add to.</param>
/// <param name="config">The application configuration.</param>
/// <param name="sectionPath">Path of the configuration section holding the secrets options.</param>
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
public static IServiceCollection AddZbSecrets(
this IServiceCollection services,
IConfiguration config,
string sectionPath)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(config);
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
services.Configure<SecretsOptions>(config.GetSection(sectionPath));
// One connection factory targets the configured SQLite path. Singleton: it is stateless
// aside from the path and opens a fresh connection per operation.
services.TryAddSingleton(sp =>
new SecretsSqliteConnectionFactory(
sp.GetRequiredService<IOptions<SecretsOptions>>().Value.SqlitePath));
services.TryAddSingleton<IMasterKeyProvider>(sp =>
MasterKeyProviderFactory.Create(
sp.GetRequiredService<IOptions<SecretsOptions>>().Value.MasterKey));
services.TryAddSingleton<ISecretCipher>(sp =>
new AesGcmEnvelopeCipher(sp.GetRequiredService<IMasterKeyProvider>()));
services.TryAddSingleton<ISecretStore>(sp =>
new SqliteSecretStore(sp.GetRequiredService<SecretsSqliteConnectionFactory>()));
services.TryAddSingleton<ISecretReplicator, NoOpSecretReplicator>();
// ONE shared resolver instance backs both the read seam (ISecretResolver) and the
// cache-invalidation seam (ISecretCacheInvalidator) so a write-path invalidation hits the
// exact cache the resolver reads from.
services.TryAddSingleton<DefaultSecretResolver>(sp =>
{
// Resolve the audit writer LAZILY so this picks up the app's AddZbAudit registration
// regardless of call order; fall back to a no-op writer when the app registers none.
IAuditWriter audit = sp.GetService<IAuditWriter>() ?? NoOpAuditWriter.Instance;
ISecretActorAccessor? actorAccessor = sp.GetService<ISecretActorAccessor>();
TimeProvider? timeProvider = sp.GetService<TimeProvider>();
TimeSpan ttl = sp.GetRequiredService<IOptions<SecretsOptions>>().Value.ResolveCacheTtl;
return new DefaultSecretResolver(
sp.GetRequiredService<ISecretStore>(),
sp.GetRequiredService<ISecretCipher>(),
audit,
ttl,
actorAccessor,
timeProvider);
});
services.TryAddSingleton<ISecretResolver>(sp => sp.GetRequiredService<DefaultSecretResolver>());
services.TryAddSingleton<ISecretCacheInvalidator>(sp => sp.GetRequiredService<DefaultSecretResolver>());
// Migrator: singleton, constructed from the already-registered connection factory. Needed
// before any store operations so the schema exists.
services.TryAddSingleton(sp =>
new SqliteSecretsStoreMigrator(sp.GetRequiredService<SecretsSqliteConnectionFactory>()));
// Hosted service that runs migrations on startup when SecretsOptions.RunMigrationsOnStartup.
services.AddHostedService<SecretsMigrationHostedService>();
return services;
}
}
@@ -0,0 +1,57 @@
using System.Runtime.Versioning;
using System.Security.Cryptography;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.MasterKey;
/// <summary>
/// Resolves the master key (KEK) by unprotecting a DPAPI-sealed blob file at
/// <see cref="MasterKeyOptions.FilePath"/> under the current-user scope. Windows-only.
/// </summary>
/// <remarks>
/// The file must contain the ciphertext produced by
/// <see cref="ProtectedData.Protect(byte[], byte[], DataProtectionScope)"/> for the 32-byte key.
/// The blob is read and unprotected fresh on every call. On a non-Windows OS
/// <see cref="ResolveKeyBytes"/> throws <see cref="PlatformNotSupportedException"/>.
/// </remarks>
[SupportedOSPlatform("windows")]
public sealed class DpapiMasterKeyProvider : MasterKeyProviderBase
{
/// <summary>Creates the provider bound to <paramref name="options"/>.</summary>
/// <param name="options">The master-key configuration (requires <see cref="MasterKeyOptions.FilePath"/>).</param>
public DpapiMasterKeyProvider(MasterKeyOptions options) : base(options)
{
}
/// <inheritdoc />
protected override byte[] ResolveKeyBytes()
{
if (!OperatingSystem.IsWindows())
{
throw new PlatformNotSupportedException(
"DPAPI master-key protection is only supported on Windows.");
}
string? path = Options.FilePath;
if (string.IsNullOrEmpty(path))
{
throw new MasterKeyUnavailableException("DPAPI master key file path is not configured.");
}
if (!File.Exists(path))
{
throw new MasterKeyUnavailableException($"DPAPI master key file '{path}' does not exist.");
}
byte[] blob = File.ReadAllBytes(path);
try
{
return ProtectedData.Unprotect(blob, optionalEntropy: null, DataProtectionScope.CurrentUser);
}
catch (CryptographicException ex)
{
throw new MasterKeyUnavailableException(
$"Failed to DPAPI-unprotect master key file '{path}': {ex.Message}");
}
}
}
@@ -0,0 +1,39 @@
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.MasterKey;
/// <summary>
/// Resolves the master key (KEK) from a base64-encoded environment variable named by
/// <see cref="MasterKeyOptions.EnvVarName"/>. The variable is read fresh on every call so a rotated
/// value takes effect immediately.
/// </summary>
public sealed class EnvironmentMasterKeyProvider : MasterKeyProviderBase
{
/// <summary>Creates the provider bound to <paramref name="options"/>.</summary>
/// <param name="options">The master-key configuration (uses <see cref="MasterKeyOptions.EnvVarName"/>).</param>
public EnvironmentMasterKeyProvider(MasterKeyOptions options) : base(options)
{
}
/// <inheritdoc />
protected override byte[] ResolveKeyBytes()
{
string envVarName = Options.EnvVarName;
string? value = Environment.GetEnvironmentVariable(envVarName);
if (string.IsNullOrEmpty(value))
{
throw new MasterKeyUnavailableException(
$"Master key environment variable '{envVarName}' is not set or is empty.");
}
try
{
return Convert.FromBase64String(value.Trim());
}
catch (FormatException ex)
{
throw new MasterKeyUnavailableException(
$"Master key environment variable '{envVarName}' is not valid base64: {ex.Message}");
}
}
}
@@ -0,0 +1,58 @@
using System.Text;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.MasterKey;
/// <summary>
/// Resolves the master key (KEK) from the file at <see cref="MasterKeyOptions.FilePath"/>. The file
/// may hold either the raw 32 key bytes, or base64 text of the key. The file is read fresh on every
/// call so a rotated file takes effect immediately.
/// </summary>
/// <remarks>
/// Precedence: if the file's raw byte count is exactly 32, those bytes are the key. Otherwise the
/// content is treated as text and base64-decoded (after trimming surrounding whitespace/newlines).
/// The base-class length check then validates the decoded result.
/// </remarks>
public sealed class FileMasterKeyProvider : MasterKeyProviderBase
{
/// <summary>Creates the provider bound to <paramref name="options"/>.</summary>
/// <param name="options">The master-key configuration (requires <see cref="MasterKeyOptions.FilePath"/>).</param>
public FileMasterKeyProvider(MasterKeyOptions options) : base(options)
{
}
/// <inheritdoc />
protected override byte[] ResolveKeyBytes()
{
string? path = Options.FilePath;
if (string.IsNullOrEmpty(path))
{
throw new MasterKeyUnavailableException("Master key file path is not configured.");
}
if (!File.Exists(path))
{
throw new MasterKeyUnavailableException($"Master key file '{path}' does not exist.");
}
byte[] raw = File.ReadAllBytes(path);
// Raw 32-byte file: those bytes ARE the key.
if (raw.Length == KeySizeBytes)
{
return raw;
}
// Otherwise treat the content as base64 text.
string text = Encoding.UTF8.GetString(raw).Trim();
try
{
return Convert.FromBase64String(text);
}
catch (FormatException ex)
{
throw new MasterKeyUnavailableException(
$"Master key file '{path}' is neither raw 32 bytes nor valid base64: {ex.Message}");
}
}
}
@@ -0,0 +1,50 @@
namespace ZB.MOM.WW.Secrets.MasterKey;
/// <summary>
/// Identifies where a <see cref="MasterKeyProviderBase"/> resolves its 32-byte master key (KEK) from.
/// </summary>
public enum MasterKeySource
{
/// <summary>Read a base64-encoded key from an environment variable.</summary>
Environment,
/// <summary>Read the key from a file (raw 32 bytes, or base64 text).</summary>
File,
/// <summary>Unprotect a DPAPI-sealed blob file (Windows / <see cref="System.Security.Cryptography.DataProtectionScope.CurrentUser"/>).</summary>
Dpapi,
}
/// <summary>
/// Configures which master-key (KEK) source a <see cref="MasterKeyProviderFactory"/> selects and how
/// it resolves the key material.
/// </summary>
/// <remarks>
/// This is the minimal master-key config shape, kept here in the MasterKey folder so the providers do
/// not take a forward dependency on the full application-level secrets options (composed later).
/// </remarks>
public sealed class MasterKeyOptions
{
/// <summary>The source the master key is resolved from. Defaults to <see cref="MasterKeySource.Environment"/>.</summary>
public MasterKeySource Source { get; set; } = MasterKeySource.Environment;
/// <summary>
/// The environment variable holding the base64-encoded 32-byte key, used when
/// <see cref="Source"/> is <see cref="MasterKeySource.Environment"/>. Defaults to
/// <c>ZB_SECRETS_MASTER_KEY</c>.
/// </summary>
public string EnvVarName { get; set; } = "ZB_SECRETS_MASTER_KEY";
/// <summary>
/// The path to the key file, used when <see cref="Source"/> is <see cref="MasterKeySource.File"/>
/// or <see cref="MasterKeySource.Dpapi"/>.
/// </summary>
public string? FilePath { get; set; }
/// <summary>
/// An optional explicit key-encryption-key identifier. When non-empty it is used verbatim as the
/// provider's <see cref="Abstractions.IMasterKeyProvider.KekId"/>; otherwise a deterministic,
/// non-secret id is derived from the key material.
/// </summary>
public string? KekId { get; set; }
}
@@ -0,0 +1,79 @@
using System.Security.Cryptography;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.MasterKey;
/// <summary>
/// Shared base for the concrete <see cref="IMasterKeyProvider"/> implementations. Centralizes the
/// 32-byte key-length validation and the non-secret <see cref="KekId"/> derivation so every source
/// (environment, file, DPAPI) fails closed identically and reports a stable identifier.
/// </summary>
/// <remarks>
/// The key is re-resolved on every <see cref="GetMasterKey"/> call so that a rotated environment or
/// file value takes effect without recreating the provider. <see cref="KekId"/> is likewise derived
/// from the currently resolved key (unless an explicit id is configured), so it stays stable for a
/// given key and shifts only when the key itself changes.
/// </remarks>
public abstract class MasterKeyProviderBase : IMasterKeyProvider
{
/// <summary>The required master-key length in bytes (AES-256).</summary>
protected const int KeySizeBytes = 32;
private readonly MasterKeyOptions _options;
/// <summary>Creates the base provider bound to the given <paramref name="options"/>.</summary>
/// <param name="options">The master-key configuration.</param>
protected MasterKeyProviderBase(MasterKeyOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
/// <summary>The master-key configuration this provider was created with.</summary>
protected MasterKeyOptions Options => _options;
/// <inheritdoc />
public ReadOnlyMemory<byte> GetMasterKey() => ResolveValidatedKey();
/// <inheritdoc />
public string KekId
{
get
{
string? explicitId = _options.KekId;
if (!string.IsNullOrEmpty(explicitId))
{
return explicitId;
}
return DeriveKekId(ResolveValidatedKey());
}
}
/// <summary>
/// Resolves the raw key material from the concrete source. Implementations throw
/// <see cref="MasterKeyUnavailableException"/> when the source is missing or malformed; length
/// validation is applied by the base class.
/// </summary>
/// <returns>The resolved key bytes (any length; validated by the caller).</returns>
protected abstract byte[] ResolveKeyBytes();
private byte[] ResolveValidatedKey()
{
byte[] key = ResolveKeyBytes();
if (key.Length != KeySizeBytes)
{
throw new MasterKeyUnavailableException(
$"Master key must be exactly {KeySizeBytes} bytes (AES-256) but was {key.Length} bytes.");
}
return key;
}
private static string DeriveKekId(byte[] key)
{
// Non-secret, deterministic id: a truncated SHA-256 of the key. The hash is one-way and
// truncated, so it identifies the key without revealing any usable key material.
string hex = Convert.ToHexString(SHA256.HashData(key));
return "sha256:" + hex[..12].ToLowerInvariant();
}
}
@@ -0,0 +1,36 @@
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.MasterKey;
/// <summary>
/// Selects and constructs the concrete <see cref="IMasterKeyProvider"/> matching a
/// <see cref="MasterKeyOptions.Source"/>.
/// </summary>
public static class MasterKeyProviderFactory
{
/// <summary>
/// Creates the master-key provider for the configured <see cref="MasterKeyOptions.Source"/>.
/// </summary>
/// <param name="options">The master-key configuration.</param>
/// <returns>The matching provider.</returns>
/// <exception cref="ArgumentNullException"><paramref name="options"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentOutOfRangeException">The configured source is not a known value.</exception>
public static IMasterKeyProvider Create(MasterKeyOptions options)
{
ArgumentNullException.ThrowIfNull(options);
return options.Source switch
{
MasterKeySource.Environment => new EnvironmentMasterKeyProvider(options),
MasterKeySource.File => new FileMasterKeyProvider(options),
// Constructing the DPAPI provider does no Windows-only work; the platform guard lives in
// DpapiMasterKeyProvider.ResolveKeyBytes, which throws PlatformNotSupportedException off
// Windows. Suppress the type-level CA1416 for this construction only.
#pragma warning disable CA1416
MasterKeySource.Dpapi => new DpapiMasterKeyProvider(options),
#pragma warning restore CA1416
_ => throw new ArgumentOutOfRangeException(
nameof(options), options.Source, "Unknown master-key source."),
};
}
}
@@ -0,0 +1,30 @@
using ZB.MOM.WW.Secrets.MasterKey;
namespace ZB.MOM.WW.Secrets;
/// <summary>
/// Application-level configuration for the ZB.MOM.WW secrets subsystem: where the SQLite store
/// lives, how the master key (KEK) is resolved, whether the schema migration runs on startup, and
/// how long decrypted values are cached. Bound from a
/// configuration section by <see cref="DependencyInjection.SecretsServiceCollectionExtensions.AddZbSecrets"/>.
/// </summary>
public sealed class SecretsOptions
{
/// <summary>The path to the SQLite database backing the secret store. Defaults to <c>secrets.db</c>.</summary>
public string SqlitePath { get; set; } = "secrets.db";
/// <summary>The master-key (KEK) resolution configuration. Defaults to a fresh <see cref="MasterKeyOptions"/>.</summary>
public MasterKeyOptions MasterKey { get; set; } = new();
/// <summary>
/// When <see langword="true"/> (the default), the SQLite schema migration runs at application
/// startup via the hosted service. The migration is idempotent, so repeated restarts are safe.
/// </summary>
public bool RunMigrationsOnStartup { get; set; } = true;
/// <summary>
/// How long a decrypted secret value is cached in process memory by the resolver. Kept short by
/// default (30 seconds) because the cache holds plaintext secret material.
/// </summary>
public TimeSpan ResolveCacheTtl { get; set; } = TimeSpan.FromSeconds(30);
}
@@ -0,0 +1,86 @@
using Microsoft.Data.Sqlite;
namespace ZB.MOM.WW.Secrets.Sqlite;
/// <summary>
/// Factory for creating and opening SQLite connections to the secret store.
/// </summary>
public sealed class SecretsSqliteConnectionFactory
{
/// <summary>
/// Busy timeout applied to every connection. SQLite retries a busy database for
/// this long before surfacing <c>SQLITE_BUSY</c>, so concurrent writers degrade
/// gracefully under load instead of failing the request path.
/// </summary>
private static readonly TimeSpan BusyTimeout = TimeSpan.FromSeconds(5);
private readonly string _sqlitePath;
/// <summary>Creates a factory targeting the database at <paramref name="sqlitePath"/>.</summary>
/// <param name="sqlitePath">Filesystem path of the SQLite database file.</param>
public SecretsSqliteConnectionFactory(string sqlitePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sqlitePath);
_sqlitePath = sqlitePath;
}
/// <summary>
/// Creates an unopened SQLite connection (Mode=ReadWriteCreate). Prefer
/// <see cref="OpenConnectionAsync"/>, which also applies WAL journaling and the
/// busy timeout.
/// </summary>
public SqliteConnection CreateConnection()
{
string? directory = Path.GetDirectoryName(_sqlitePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
SqliteConnectionStringBuilder builder = new()
{
DataSource = _sqlitePath,
Mode = SqliteOpenMode.ReadWriteCreate,
Pooling = true,
DefaultTimeout = (int)BusyTimeout.TotalSeconds,
};
return new SqliteConnection(builder.ToString());
}
/// <summary>
/// Creates a SQLite connection, opens it, and configures WAL journaling and a
/// non-zero busy timeout so concurrent readers and writers degrade gracefully
/// rather than surfacing <c>SQLITE_BUSY</c> as a hard failure.
/// </summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>An opened and configured SQLite connection.</returns>
public async Task<SqliteConnection> OpenConnectionAsync(CancellationToken cancellationToken)
{
SqliteConnection connection = CreateConnection();
try
{
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
await ConfigureConnectionAsync(connection, cancellationToken).ConfigureAwait(false);
return connection;
}
catch
{
await connection.DisposeAsync().ConfigureAwait(false);
throw;
}
}
private static async Task ConfigureConnectionAsync(
SqliteConnection connection,
CancellationToken cancellationToken)
{
// WAL is a persistent, database-level setting; re-applying it per connection
// is cheap and a no-op once set. busy_timeout is per-connection state.
await using SqliteCommand command = connection.CreateCommand();
command.CommandText =
$"PRAGMA journal_mode=WAL; PRAGMA busy_timeout={(int)BusyTimeout.TotalMilliseconds};";
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,302 @@
using System.Data;
using System.Globalization;
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Sqlite;
/// <summary>
/// SQLite-backed <see cref="ISecretStore"/>. Persists the envelope-encrypted
/// <see cref="StoredSecret"/> rows and their safe <see cref="SecretMetadata"/> projection,
/// using parameterized commands throughout. Local writes go through
/// <see cref="UpsertAsync"/> (which bumps the revision); replicated rows go through
/// <see cref="ApplyReplicatedAsync"/> (last-writer-wins, applied verbatim).
/// </summary>
/// <remarks>Timestamps are stored as round-trippable ISO-8601 (<c>"O"</c>) TEXT;
/// <c>content_type</c> as the enum name; <c>is_deleted</c> as a 0/1 integer.</remarks>
public sealed class SqliteSecretStore(SecretsSqliteConnectionFactory connectionFactory) : ISecretStore
{
// All columns of the secret table, in schema order, for full-row reads.
private const string AllColumns =
"name, description, content_type, ciphertext, nonce, tag, wrapped_dek, wrap_nonce, wrap_tag, " +
"kek_id, revision, is_deleted, deleted_utc, created_utc, updated_utc, created_by, updated_by";
// The safe metadata projection — deliberately excludes every ciphertext / crypto BLOB column.
private const string MetadataColumns =
"name, description, content_type, kek_id, revision, is_deleted, created_utc, updated_utc, created_by, updated_by";
/// <inheritdoc />
public async Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct)
{
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = $"SELECT {AllColumns} FROM secret WHERE name = $name;";
command.Parameters.AddWithValue("$name", name.Value);
await using SqliteDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
if (!await reader.ReadAsync(ct).ConfigureAwait(false))
{
return null;
}
return ReadStoredSecret(reader);
}
/// <inheritdoc />
public async Task UpsertAsync(StoredSecret row, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(row);
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
string now = DateTimeOffset.UtcNow.ToString("O");
// INSERT establishes revision 0 / created==updated==now; ON CONFLICT overwrites the
// crypto material in place, bumps the revision, refreshes updated_utc/updated_by, and
// clears any tombstone — but deliberately preserves created_utc / created_by.
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
INSERT INTO secret (
name, description, content_type, ciphertext, nonce, tag,
wrapped_dek, wrap_nonce, wrap_tag, kek_id, revision,
is_deleted, deleted_utc, created_utc, updated_utc, created_by, updated_by)
VALUES (
$name, $description, $content_type, $ciphertext, $nonce, $tag,
$wrapped_dek, $wrap_nonce, $wrap_tag, $kek_id, 0,
0, NULL, $now, $now, $created_by, $updated_by)
ON CONFLICT(name) DO UPDATE SET
description = excluded.description,
content_type = excluded.content_type,
ciphertext = excluded.ciphertext,
nonce = excluded.nonce,
tag = excluded.tag,
wrapped_dek = excluded.wrapped_dek,
wrap_nonce = excluded.wrap_nonce,
wrap_tag = excluded.wrap_tag,
kek_id = excluded.kek_id,
revision = secret.revision + 1,
updated_utc = $now,
updated_by = excluded.updated_by,
is_deleted = 0,
deleted_utc = NULL;
""";
BindCryptoColumns(command, row);
command.Parameters.AddWithValue("$now", now);
command.Parameters.AddWithValue("$created_by", (object?)row.CreatedBy ?? DBNull.Value);
command.Parameters.AddWithValue("$updated_by", (object?)row.UpdatedBy ?? DBNull.Value);
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct)
{
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
string now = DateTimeOffset.UtcNow.ToString("O");
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
UPDATE secret SET
is_deleted = 1,
deleted_utc = $now,
revision = revision + 1,
updated_utc = $now,
updated_by = $actor
WHERE name = $name AND is_deleted = 0;
""";
command.Parameters.AddWithValue("$now", now);
command.Parameters.AddWithValue("$actor", (object?)actor ?? DBNull.Value);
command.Parameters.AddWithValue("$name", name.Value);
int rowsAffected = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return rowsAffected > 0;
}
/// <inheritdoc />
public async Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct)
{
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText =
$"SELECT {MetadataColumns} FROM secret WHERE ($include_deleted OR is_deleted = 0) ORDER BY name;";
command.Parameters.AddWithValue("$include_deleted", includeDeleted ? 1 : 0);
var results = new List<SecretMetadata>();
await using SqliteDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
results.Add(new SecretMetadata
{
Name = new SecretName(reader.GetString(0)),
Description = reader.IsDBNull(1) ? null : reader.GetString(1),
ContentType = Enum.Parse<SecretContentType>(reader.GetString(2)),
KekId = reader.GetString(3),
Revision = reader.GetInt64(4),
IsDeleted = reader.GetInt64(5) != 0,
CreatedUtc = ParseUtc(reader.GetString(6)),
UpdatedUtc = ParseUtc(reader.GetString(7)),
CreatedBy = reader.IsDBNull(8) ? null : reader.GetString(8),
UpdatedBy = reader.IsDBNull(9) ? null : reader.GetString(9),
});
}
return results;
}
/// <inheritdoc />
public async Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct)
{
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT name, revision, updated_utc, is_deleted FROM secret ORDER BY name;";
var results = new List<SecretManifestEntry>();
await using SqliteDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
results.Add(new SecretManifestEntry
{
Name = new SecretName(reader.GetString(0)),
Revision = reader.GetInt64(1),
UpdatedUtc = ParseUtc(reader.GetString(2)),
IsDeleted = reader.GetInt64(3) != 0,
});
}
return results;
}
/// <inheritdoc />
public async Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(row);
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
await using SqliteTransaction transaction = (SqliteTransaction)
await connection.BeginTransactionAsync(IsolationLevel.Serializable, ct).ConfigureAwait(false);
// Read the local (updated_utc, revision) so we can apply last-writer-wins.
await using (SqliteCommand read = connection.CreateCommand())
{
read.Transaction = transaction;
read.CommandText = "SELECT updated_utc, revision FROM secret WHERE name = $name;";
read.Parameters.AddWithValue("$name", row.Name.Value);
await using SqliteDataReader reader = await read.ExecuteReaderAsync(ct).ConfigureAwait(false);
if (await reader.ReadAsync(ct).ConfigureAwait(false))
{
DateTimeOffset localUpdated = ParseUtc(reader.GetString(0));
long localRevision = reader.GetInt64(1);
// Incoming wins only if strictly newer by (updated_utc, then revision).
bool incomingIsNewer =
row.UpdatedUtc > localUpdated ||
(row.UpdatedUtc == localUpdated && row.Revision > localRevision);
if (!incomingIsNewer)
{
await transaction.CommitAsync(ct).ConfigureAwait(false);
return;
}
}
}
// Apply the incoming row VERBATIM — its own revision, timestamps, tombstone flag, and
// crypto material — with no revision bump (this is the replication path, not a local write).
await using (SqliteCommand upsert = connection.CreateCommand())
{
upsert.Transaction = transaction;
upsert.CommandText = """
INSERT INTO secret (
name, description, content_type, ciphertext, nonce, tag,
wrapped_dek, wrap_nonce, wrap_tag, kek_id, revision,
is_deleted, deleted_utc, created_utc, updated_utc, created_by, updated_by)
VALUES (
$name, $description, $content_type, $ciphertext, $nonce, $tag,
$wrapped_dek, $wrap_nonce, $wrap_tag, $kek_id, $revision,
$is_deleted, $deleted_utc, $created_utc, $updated_utc, $created_by, $updated_by)
ON CONFLICT(name) DO UPDATE SET
description = excluded.description,
content_type = excluded.content_type,
ciphertext = excluded.ciphertext,
nonce = excluded.nonce,
tag = excluded.tag,
wrapped_dek = excluded.wrapped_dek,
wrap_nonce = excluded.wrap_nonce,
wrap_tag = excluded.wrap_tag,
kek_id = excluded.kek_id,
revision = excluded.revision,
is_deleted = excluded.is_deleted,
deleted_utc = excluded.deleted_utc,
created_utc = excluded.created_utc,
updated_utc = excluded.updated_utc,
created_by = excluded.created_by,
updated_by = excluded.updated_by;
""";
BindCryptoColumns(upsert, row);
upsert.Parameters.AddWithValue("$revision", row.Revision);
upsert.Parameters.AddWithValue("$is_deleted", row.IsDeleted ? 1 : 0);
upsert.Parameters.AddWithValue("$deleted_utc", (object?)row.DeletedUtc?.ToString("O") ?? DBNull.Value);
upsert.Parameters.AddWithValue("$created_utc", row.CreatedUtc.ToString("O"));
upsert.Parameters.AddWithValue("$updated_utc", row.UpdatedUtc.ToString("O"));
upsert.Parameters.AddWithValue("$created_by", (object?)row.CreatedBy ?? DBNull.Value);
upsert.Parameters.AddWithValue("$updated_by", (object?)row.UpdatedBy ?? DBNull.Value);
await upsert.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
await transaction.CommitAsync(ct).ConfigureAwait(false);
}
// Binds the identity, description, content-type, KEK id, and all six crypto BLOB columns
// shared by every insert path.
private static void BindCryptoColumns(SqliteCommand command, StoredSecret row)
{
command.Parameters.AddWithValue("$name", row.Name.Value);
command.Parameters.AddWithValue("$description", (object?)row.Description ?? DBNull.Value);
command.Parameters.AddWithValue("$content_type", row.ContentType.ToString());
command.Parameters.AddWithValue("$ciphertext", row.Ciphertext);
command.Parameters.AddWithValue("$nonce", row.Nonce);
command.Parameters.AddWithValue("$tag", row.Tag);
command.Parameters.AddWithValue("$wrapped_dek", row.WrappedDek);
command.Parameters.AddWithValue("$wrap_nonce", row.WrapNonce);
command.Parameters.AddWithValue("$wrap_tag", row.WrapTag);
command.Parameters.AddWithValue("$kek_id", row.KekId);
}
private static StoredSecret ReadStoredSecret(SqliteDataReader reader) => new()
{
Name = new SecretName(reader.GetString(0)),
Description = reader.IsDBNull(1) ? null : reader.GetString(1),
ContentType = Enum.Parse<SecretContentType>(reader.GetString(2)),
Ciphertext = reader.GetFieldValue<byte[]>(3),
Nonce = reader.GetFieldValue<byte[]>(4),
Tag = reader.GetFieldValue<byte[]>(5),
WrappedDek = reader.GetFieldValue<byte[]>(6),
WrapNonce = reader.GetFieldValue<byte[]>(7),
WrapTag = reader.GetFieldValue<byte[]>(8),
KekId = reader.GetString(9),
Revision = reader.GetInt64(10),
IsDeleted = reader.GetInt64(11) != 0,
DeletedUtc = reader.IsDBNull(12) ? null : ParseUtc(reader.GetString(12)),
CreatedUtc = ParseUtc(reader.GetString(13)),
UpdatedUtc = ParseUtc(reader.GetString(14)),
CreatedBy = reader.IsDBNull(15) ? null : reader.GetString(15),
UpdatedBy = reader.IsDBNull(16) ? null : reader.GetString(16),
};
private static DateTimeOffset ParseUtc(string value) =>
DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
}
@@ -0,0 +1,59 @@
namespace ZB.MOM.WW.Secrets.Sqlite;
/// <summary>
/// Schema constants and table DDL for the secret SQLite store.
/// </summary>
public static class SqliteSecretsSchema
{
/// <summary>
/// The schema version this build creates and supports. This is the first version, so all DDL is a
/// single-shot <c>CREATE ... IF NOT EXISTS</c>. The migrator refuses an on-disk version
/// <em>newer</em> than this. Future additive columns (nullable <c>ALTER TABLE</c>s, gated by a
/// <c>PRAGMA table_info</c> existence probe) would bump this and register a forward migration.
/// </summary>
public const int CurrentVersion = 1;
/// <summary>Name of the single-row table tracking the applied schema version.</summary>
public const string SchemaVersionTable = "schema_version";
/// <summary>Name of the table storing envelope-encrypted secret records.</summary>
public const string SecretTable = "secret";
/// <summary>DDL creating the single-row schema-version table.</summary>
public const string CreateSchemaVersionTable = """
CREATE TABLE IF NOT EXISTS schema_version (
id INTEGER PRIMARY KEY CHECK (id = 1),
version INTEGER NOT NULL,
applied_utc TEXT NOT NULL
);
""";
/// <summary>DDL creating the envelope-encrypted secret record table.</summary>
public const string CreateSecretTable = """
CREATE TABLE IF NOT EXISTS secret (
name TEXT PRIMARY KEY,
description TEXT NULL,
content_type TEXT NOT NULL,
ciphertext BLOB NOT NULL,
nonce BLOB NOT NULL,
tag BLOB NOT NULL,
wrapped_dek BLOB NOT NULL,
wrap_nonce BLOB NOT NULL,
wrap_tag BLOB NOT NULL,
kek_id TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 0,
is_deleted INTEGER NOT NULL DEFAULT 0,
deleted_utc TEXT NULL,
created_utc TEXT NOT NULL,
updated_utc TEXT NOT NULL,
created_by TEXT NULL,
updated_by TEXT NULL
);
""";
/// <summary>DDL creating supporting indexes (idempotent).</summary>
public const string CreateIndexes = """
CREATE INDEX IF NOT EXISTS ix_secret_updated_utc
ON secret (updated_utc);
""";
}
@@ -0,0 +1,138 @@
using System.Globalization;
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Sqlite;
/// <summary>
/// Creates the secret store schema and records the applied version. Idempotent: it is
/// safe to run repeatedly. Refuses to run against a database whose on-disk version is
/// newer than this build supports.
/// </summary>
public sealed class SqliteSecretsStoreMigrator(SecretsSqliteConnectionFactory connectionFactory)
{
/// <summary>Applies the schema migration to the secret store.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <exception cref="SecretStoreMigrationException">
/// The on-disk schema version is newer than <see cref="SqliteSecretsSchema.CurrentVersion"/>.
/// </exception>
public async Task MigrateAsync(CancellationToken cancellationToken)
{
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using SqliteTransaction transaction =
(SqliteTransaction)await connection.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, cancellationToken).ConfigureAwait(false);
int existingVersion =
await ReadExistingSchemaVersionAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
if (existingVersion > SqliteSecretsSchema.CurrentVersion)
{
throw new SecretStoreMigrationException(
$"Secret database schema version {existingVersion} is newer than supported version {SqliteSecretsSchema.CurrentVersion}.");
}
await ApplySchemaAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
await WriteSchemaVersionAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
private static async Task<int> ReadExistingSchemaVersionAsync(
SqliteConnection connection,
SqliteTransaction transaction,
CancellationToken cancellationToken)
{
await using SqliteCommand tableExistsCommand = connection.CreateCommand();
tableExistsCommand.Transaction = transaction;
tableExistsCommand.CommandText = """
SELECT COUNT(*)
FROM sqlite_master
WHERE type = 'table' AND name = $table_name;
""";
tableExistsCommand.Parameters.AddWithValue("$table_name", SqliteSecretsSchema.SchemaVersionTable);
long tableCount =
(long)(await tableExistsCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) ?? 0L);
if (tableCount == 0)
{
return 0;
}
await using SqliteCommand versionCommand = connection.CreateCommand();
versionCommand.Transaction = transaction;
versionCommand.CommandText = """
SELECT version
FROM schema_version
WHERE id = 1;
""";
object? version = await versionCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return version is null || version == DBNull.Value
? 0
: Convert.ToInt32(version, CultureInfo.InvariantCulture);
}
// Single-shot create of the final schema (all DDL is CREATE ... IF NOT EXISTS, so it is idempotent
// against an already-provisioned database). The applied version is stamped separately by
// WriteSchemaVersionAsync.
//
// Forward-migration hook: when CurrentVersion advances past 1, any additive column added to the
// secret table must ALSO be applied here to pre-existing databases via an idempotent
// "ALTER TABLE secret ADD COLUMN ..." guarded by a `PRAGMA table_info(secret)` column-existence
// probe (SQLite has no "ADD COLUMN IF NOT EXISTS"). Fully consume and dispose the PRAGMA reader
// before issuing the ALTER on the same connection/transaction. See the sibling Auth migrator
// (SqliteAuthStoreMigrator.EnsureApiKeysColumnAsync) for the proven pattern. Schema v1 has no
// additive ALTERs, so the CREATE above is the whole migration.
private static async Task ApplySchemaAsync(
SqliteConnection connection,
SqliteTransaction transaction,
CancellationToken cancellationToken)
{
await ExecuteNonQueryAsync(
connection,
transaction,
string.Join(
"\n",
SqliteSecretsSchema.CreateSchemaVersionTable,
SqliteSecretsSchema.CreateSecretTable,
SqliteSecretsSchema.CreateIndexes),
cancellationToken).ConfigureAwait(false);
}
private static async Task WriteSchemaVersionAsync(
SqliteConnection connection,
SqliteTransaction transaction,
CancellationToken cancellationToken)
{
await using SqliteCommand versionCommand = connection.CreateCommand();
versionCommand.Transaction = transaction;
versionCommand.CommandText = """
INSERT INTO schema_version (id, version, applied_utc)
VALUES (1, $version, $applied_utc)
ON CONFLICT(id) DO UPDATE SET
version = excluded.version,
applied_utc = excluded.applied_utc;
""";
versionCommand.Parameters.AddWithValue("$version", SqliteSecretsSchema.CurrentVersion);
versionCommand.Parameters.AddWithValue("$applied_utc", DateTimeOffset.UtcNow.ToString("O"));
await versionCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static async Task ExecuteNonQueryAsync(
SqliteConnection connection,
SqliteTransaction transaction,
string commandText,
CancellationToken cancellationToken)
{
await using SqliteCommand command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = commandText;
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.Secrets.Abstractions\ZB.MOM.WW.Secrets.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="ZB.MOM.WW.Audit" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<IsPackable>true</IsPackable>
<PackageId>ZB.MOM.WW.Secrets</PackageId>
<Authors>ZB.MOM.WW</Authors>
<Description>Envelope-encrypted (AES-256-GCM) secrets store + resolver for the ZB.MOM.WW SCADA family.</Description>
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-secrets</PackageProjectUrl>
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-secrets</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.Secrets.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,156 @@
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Cli;
using ZB.MOM.WW.Secrets.Crypto;
using ZB.MOM.WW.Secrets.Sqlite;
using ZB.MOM.WW.Secrets.Tests.Fakes;
namespace ZB.MOM.WW.Secrets.Tests.Cli;
/// <summary>
/// Exercises the headless <see cref="SecretCommands"/> layer over a real migrated SQLite store,
/// the real envelope cipher, and the real resolver — asserting round-trips, exit codes, and the
/// hard invariant that list/set output never carries a plaintext value.
/// </summary>
public sealed class SecretCommandsTests : IAsyncLifetime, IDisposable
{
private readonly string _dbPath =
Path.Combine(Path.GetTempPath(), $"zb-secrets-cli-{Guid.NewGuid():N}.db");
private readonly SecretsSqliteConnectionFactory _factory;
private readonly SqliteSecretStore _store;
private readonly AesGcmEnvelopeCipher _cipher;
private readonly DefaultSecretResolver _resolver;
public SecretCommandsTests()
{
_factory = new SecretsSqliteConnectionFactory(_dbPath);
_store = new SqliteSecretStore(_factory);
_cipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("kek-cli"));
// A near-zero TTL keeps the resolver from serving a stale plaintext across a rotate/remove.
_resolver = new DefaultSecretResolver(
_store, _cipher, NoOpAuditWriter.Instance, TimeSpan.Zero);
}
public async Task InitializeAsync() =>
await new SqliteSecretsStoreMigrator(_factory).MigrateAsync(CancellationToken.None);
public Task DisposeAsync() => Task.CompletedTask;
public void Dispose()
{
if (File.Exists(_dbPath))
{
try { File.Delete(_dbPath); } catch (IOException) { /* best-effort temp cleanup */ }
}
}
private (SecretCommands cmd, StringWriter output) NewSut()
{
var output = new StringWriter();
return (new SecretCommands(_store, _cipher, _resolver, output), output);
}
private static CancellationToken Ct => CancellationToken.None;
[Fact]
public async Task Set_Then_Get_RoundTrips()
{
var (cmd, setOut) = NewSut();
int setRc = await cmd.SetAsync(
"sql/db-conn", "hunter2", SecretContentType.ConnectionString, "prod db", "alice", Ct);
Assert.Equal(0, setRc);
// set confirmation must NOT leak the value.
Assert.DoesNotContain("hunter2", setOut.ToString());
Assert.Contains("\"action\":\"set\"", setOut.ToString());
Assert.Contains("sql/db-conn", setOut.ToString());
// A fresh writer for get so we can assert exactly the plaintext came back.
var getOut = new StringWriter();
var getCmd = new SecretCommands(_store, _cipher, _resolver, getOut);
int getRc = await getCmd.GetAsync("sql/db-conn", Ct);
Assert.Equal(0, getRc);
Assert.Equal("hunter2", getOut.ToString().TrimEnd('\r', '\n'));
}
[Fact]
public async Task List_PrintsMetadata_NoValues()
{
var (cmd, _) = NewSut();
await cmd.SetAsync("app/one", "value-one", SecretContentType.Text, null, "alice", Ct);
await cmd.SetAsync("app/two", "value-two", SecretContentType.Json, "second", "alice", Ct);
var listOut = new StringWriter();
var listCmd = new SecretCommands(_store, _cipher, _resolver, listOut);
int rc = await listCmd.ListAsync(includeDeleted: false, Ct);
string json = listOut.ToString();
Assert.Equal(0, rc);
Assert.Contains("app/one", json);
Assert.Contains("app/two", json);
Assert.DoesNotContain("value-one", json);
Assert.DoesNotContain("value-two", json);
}
[Fact]
public async Task Rm_Tombstones()
{
var (cmd, _) = NewSut();
await cmd.SetAsync("app/gone", "bye", SecretContentType.Text, null, "alice", Ct);
var rm1Out = new StringWriter();
int rc1 = await new SecretCommands(_store, _cipher, _resolver, rm1Out).RemoveAsync("app/gone", "alice", Ct);
Assert.Equal(0, rc1);
Assert.Contains("\"found\":true", rm1Out.ToString());
// Second remove: already tombstoned → not found.
var rm2Out = new StringWriter();
int rc2 = await new SecretCommands(_store, _cipher, _resolver, rm2Out).RemoveAsync("app/gone", "alice", Ct);
Assert.NotEqual(0, rc2);
Assert.Contains("\"found\":false", rm2Out.ToString());
// Get after removal → not-found.
var getOut = new StringWriter();
int getRc = await new SecretCommands(_store, _cipher, _resolver, getOut).GetAsync("app/gone", Ct);
Assert.NotEqual(0, getRc);
Assert.Contains("not-found", getOut.ToString());
}
[Fact]
public async Task Rotate_RequiresExisting_OverwritesInPlace()
{
// Rotate on a missing secret → non-zero, nothing created.
var missingOut = new StringWriter();
int missingRc = await new SecretCommands(_store, _cipher, _resolver, missingOut)
.RotateAsync("app/rot", "v1", SecretContentType.Text, null, "alice", Ct);
Assert.NotEqual(0, missingRc);
// After a set, rotate to a new value → 0, and get returns the new value.
await new SecretCommands(_store, _cipher, _resolver, new StringWriter())
.SetAsync("app/rot", "v1", SecretContentType.Text, null, "alice", Ct);
var rotateOut = new StringWriter();
int rotateRc = await new SecretCommands(_store, _cipher, _resolver, rotateOut)
.RotateAsync("app/rot", "v2", SecretContentType.Text, null, "bob", Ct);
Assert.Equal(0, rotateRc);
Assert.Contains("\"action\":\"rotate\"", rotateOut.ToString());
Assert.DoesNotContain("v2", rotateOut.ToString());
var getOut = new StringWriter();
await new SecretCommands(_store, _cipher, _resolver, getOut).GetAsync("app/rot", Ct);
Assert.Equal("v2", getOut.ToString().TrimEnd('\r', '\n'));
}
[Fact]
public async Task Get_MissingSecret_ReturnsNonZero()
{
var (cmd, output) = NewSut();
int rc = await cmd.GetAsync("nope/missing", Ct);
Assert.NotEqual(0, rc);
Assert.Contains("not-found", output.ToString());
}
}
@@ -0,0 +1,132 @@
using Microsoft.Extensions.Configuration;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Configuration;
namespace ZB.MOM.WW.Secrets.Tests.Configuration;
public sealed class SecretReferenceExpanderTests
{
private sealed class FakeSecretResolver : ISecretResolver
{
private readonly Dictionary<string, string?> _values;
public FakeSecretResolver(Dictionary<string, string?> values) => _values = values;
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
Task.FromResult(_values.TryGetValue(name.Value, out string? v) ? v : null);
}
private static SecretReferenceExpander Expander(params (string Name, string? Value)[] secrets)
{
var dict = new Dictionary<string, string?>();
foreach ((string name, string? value) in secrets)
{
dict[new SecretName(name).Value] = value;
}
return new SecretReferenceExpander(new FakeSecretResolver(dict));
}
[Fact]
public async Task ExpandAsync_SingleToken()
{
SecretReferenceExpander expander = Expander(("sql/foo", "hunter2"));
string result = await expander.ExpandAsync("Password=${secret:sql/foo}", CancellationToken.None);
Assert.Equal("Password=hunter2", result);
}
[Fact]
public async Task ExpandAsync_MultipleTokens()
{
SecretReferenceExpander expander = Expander(("sql/foo", "hunter2"), ("sql/bar", "swordfish"));
string result = await expander.ExpandAsync(
"u=${secret:sql/foo};p=${secret:sql/bar}", CancellationToken.None);
Assert.Equal("u=hunter2;p=swordfish", result);
}
[Fact]
public async Task ExpandAsync_NoToken_Unchanged()
{
SecretReferenceExpander expander = Expander(("sql/foo", "hunter2"));
string result = await expander.ExpandAsync("no tokens here", CancellationToken.None);
Assert.Equal("no tokens here", result);
}
[Fact]
public async Task ExpandAsync_MissingSecret_Throws()
{
SecretReferenceExpander expander = Expander();
SecretNotFoundException ex = await Assert.ThrowsAsync<SecretNotFoundException>(
() => expander.ExpandAsync("Password=${secret:sql/absent}", CancellationToken.None));
Assert.Contains("sql/absent", ex.Message);
Assert.Contains("${secret:sql/absent}", ex.Message);
}
[Fact]
public async Task ExpandConfigurationAsync_RewritesMatchingKeys()
{
SecretReferenceExpander expander = Expander(("sql/foo", "hunter2"));
IConfigurationRoot config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:Db"] = "Server=.;Password=${secret:sql/foo}",
["Plain:Value"] = "unchanged",
})
.Build();
await expander.ExpandConfigurationAsync(config, CancellationToken.None);
Assert.Contains("hunter2", config["ConnectionStrings:Db"]);
Assert.DoesNotContain("${secret:", config["ConnectionStrings:Db"]);
Assert.Equal("unchanged", config["Plain:Value"]);
}
[Fact]
public async Task ExpandConfigurationAsync_MissingSecret_FailsClosed()
{
SecretReferenceExpander expander = Expander();
IConfigurationRoot config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:Db"] = "Server=.;Password=${secret:sql/absent}",
})
.Build();
await Assert.ThrowsAsync<SecretNotFoundException>(
() => expander.ExpandConfigurationAsync(config, CancellationToken.None));
}
[Fact]
public async Task ExpandConfigurationAsync_SkipsCommentKeys()
{
// A "_comment" key may document the ${secret:...} syntax with a literal (even malformed or
// not-yet-seeded) example token; it must be left untouched — never resolved, never throwing —
// while a real sibling key still expands. Regression: an unseeded/ellipsis example in a comment
// previously crashed boot (SecretName rejects '...').
SecretReferenceExpander expander = Expander(("sql/foo", "hunter2"));
IConfigurationRoot config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["_secretsComment"] = "e.g. Password=${secret:...} or ${secret:sql/not-seeded}",
["Nested:_comment"] = "also skipped: ${secret:sql/also-absent}",
["ConnectionStrings:Db"] = "Server=.;Password=${secret:sql/foo}",
})
.Build();
await expander.ExpandConfigurationAsync(config, CancellationToken.None);
// Comment keys untouched (literal token preserved, no throw despite absent/malformed names).
Assert.Equal("e.g. Password=${secret:...} or ${secret:sql/not-seeded}", config["_secretsComment"]);
Assert.Contains("${secret:sql/also-absent}", config["Nested:_comment"]);
// Real key still expanded.
Assert.Equal("Server=.;Password=hunter2", config["ConnectionStrings:Db"]);
}
}
@@ -0,0 +1,125 @@
using System.Text;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Crypto;
using ZB.MOM.WW.Secrets.Tests.Fakes;
namespace ZB.MOM.WW.Secrets.Tests.Crypto;
public class AesGcmEnvelopeCipherTests
{
[Fact]
public void RoundTrips()
{
var provider = new FakeMasterKeyProvider("k1");
var cipher = new AesGcmEnvelopeCipher(provider);
var name = new SecretName("sql/foo");
StoredSecret row = cipher.Encrypt(name, "hunter2", SecretContentType.Text);
Assert.Equal("k1", row.KekId);
// Ciphertext must not leak the plaintext in the clear.
byte[] plaintextBytes = Encoding.UTF8.GetBytes("hunter2");
Assert.False(Contains(row.Ciphertext, plaintextBytes));
string decrypted = cipher.Decrypt(row);
Assert.Equal("hunter2", decrypted);
}
[Fact]
public void TwoEncryptsProduceDifferentNonces()
{
var provider = new FakeMasterKeyProvider("k1");
var cipher = new AesGcmEnvelopeCipher(provider);
var name = new SecretName("sql/foo");
// Same plaintext + name encrypted twice must yield a fresh nonce (and therefore a distinct
// ciphertext) each time — the AES-GCM (key, nonce) uniqueness invariant.
StoredSecret first = cipher.Encrypt(name, "hunter2", SecretContentType.Text);
StoredSecret second = cipher.Encrypt(name, "hunter2", SecretContentType.Text);
Assert.False(first.Nonce.AsSpan().SequenceEqual(second.Nonce));
Assert.False(first.Ciphertext.AsSpan().SequenceEqual(second.Ciphertext));
}
[Fact]
public void TamperedCiphertextThrows()
{
var provider = new FakeMasterKeyProvider("k1");
var cipher = new AesGcmEnvelopeCipher(provider);
var name = new SecretName("sql/foo");
StoredSecret row = cipher.Encrypt(name, "hunter2", SecretContentType.Text);
row.Ciphertext[0] ^= 0xFF;
Assert.Throws<SecretDecryptionException>(() => cipher.Decrypt(row));
}
[Fact]
public void AadBindingRejectsRenamedRow()
{
var provider = new FakeMasterKeyProvider("k1");
var cipher = new AesGcmEnvelopeCipher(provider);
StoredSecret row = cipher.Encrypt(new SecretName("sql/foo"), "hunter2", SecretContentType.Text);
var renamed = row with { Name = new SecretName("sql/bar") };
Assert.Throws<SecretDecryptionException>(() => cipher.Decrypt(renamed));
}
[Fact]
public void WrongKekThrows()
{
// Both providers report KekId "k1" but hold different random key bytes.
var providerA = new FakeMasterKeyProvider("k1");
var providerB = new FakeMasterKeyProvider("k1");
var cipherA = new AesGcmEnvelopeCipher(providerA);
var cipherB = new AesGcmEnvelopeCipher(providerB);
StoredSecret row = cipherA.Encrypt(new SecretName("sql/foo"), "hunter2", SecretContentType.Text);
// Matching KekId string but wrong key material must still fail closed (unwrap tag mismatch).
Assert.Throws<SecretDecryptionException>(() => cipherB.Decrypt(row));
}
[Fact]
public void UnknownKekIdThrows()
{
var providerK1 = new FakeMasterKeyProvider("k1");
var providerK2 = new FakeMasterKeyProvider("k2");
var cipherK1 = new AesGcmEnvelopeCipher(providerK1);
var cipherK2 = new AesGcmEnvelopeCipher(providerK2);
StoredSecret row = cipherK1.Encrypt(new SecretName("sql/foo"), "hunter2", SecretContentType.Text);
var ex = Assert.Throws<SecretDecryptionException>(() => cipherK2.Decrypt(row));
Assert.Contains("unknown KEK", ex.Message, StringComparison.Ordinal);
}
private static bool Contains(byte[] haystack, byte[] needle)
{
if (needle.Length == 0 || haystack.Length < needle.Length)
{
return false;
}
for (int i = 0; i <= haystack.Length - needle.Length; i++)
{
bool match = true;
for (int j = 0; j < needle.Length; j++)
{
if (haystack[i + j] != needle[j])
{
match = false;
break;
}
}
if (match)
{
return true;
}
}
return false;
}
}
@@ -0,0 +1,206 @@
using System.Text.Json;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Crypto;
using ZB.MOM.WW.Secrets.Tests.Fakes;
namespace ZB.MOM.WW.Secrets.Tests;
public class DefaultSecretResolverTests
{
private static readonly TimeSpan Ttl = TimeSpan.FromSeconds(30);
/// <summary>
/// Builds a real encrypted row for <paramref name="plaintext"/> using the real cipher, then
/// stamps a revision + timestamps (the cipher leaves those at defaults) and optionally tombstones
/// it, so tests exercise the true decrypt path rather than a fake.
/// </summary>
private static StoredSecret MakeRow(
AesGcmEnvelopeCipher cipher, SecretName name, string plaintext, bool deleted = false)
{
StoredSecret row = cipher.Encrypt(name, plaintext, SecretContentType.Text);
return row with
{
Revision = 1,
CreatedUtc = DateTimeOffset.UnixEpoch,
UpdatedUtc = DateTimeOffset.UnixEpoch,
IsDeleted = deleted,
DeletedUtc = deleted ? DateTimeOffset.UnixEpoch : null,
};
}
private static (DefaultSecretResolver resolver, CountingSecretStore store, CapturingAuditWriter audit,
AesGcmEnvelopeCipher cipher, MutableTimeProvider clock) NewSut(ISecretActorAccessor? actor = null)
{
var cipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("k1"));
var store = new CountingSecretStore();
var audit = new CapturingAuditWriter();
var clock = new MutableTimeProvider(DateTimeOffset.UnixEpoch);
var resolver = new DefaultSecretResolver(store, cipher, audit, Ttl, actor, clock);
return (resolver, store, audit, cipher, clock);
}
[Fact]
public async Task Get_ReturnsDecryptedPlaintext_AndAuditsSuccess()
{
var (resolver, store, audit, cipher, _) = NewSut();
var name = new SecretName("sql/foo");
store.Seed(MakeRow(cipher, name, "hunter2"));
string? value = await resolver.GetAsync(name, CancellationToken.None);
Assert.Equal("hunter2", value);
AuditEvent evt = Assert.Single(audit.Events);
Assert.Equal(AuditOutcome.Success, evt.Outcome);
Assert.Equal("sql/foo", evt.Target);
// Hard guarantee: the plaintext must NEVER appear in any serialized audit field.
foreach (AuditEvent captured in audit.Events)
{
string json = JsonSerializer.Serialize(captured);
Assert.DoesNotContain("hunter2", json, StringComparison.Ordinal);
}
}
[Fact]
public async Task Get_CachesWithinTtl()
{
var (resolver, store, _, cipher, _) = NewSut();
var name = new SecretName("sql/foo");
store.Seed(MakeRow(cipher, name, "hunter2"));
string? first = await resolver.GetAsync(name, CancellationToken.None);
string? second = await resolver.GetAsync(name, CancellationToken.None);
Assert.Equal("hunter2", first);
Assert.Equal("hunter2", second);
Assert.Equal(1, store.GetCount); // second call served from the TTL cache
}
[Fact]
public async Task Get_ReloadsAfterTtlExpiry()
{
var (resolver, store, _, cipher, clock) = NewSut();
var name = new SecretName("sql/foo");
store.Seed(MakeRow(cipher, name, "hunter2"));
await resolver.GetAsync(name, CancellationToken.None);
clock.Advance(Ttl + TimeSpan.FromSeconds(1)); // past the TTL
await resolver.GetAsync(name, CancellationToken.None);
Assert.Equal(2, store.GetCount);
}
[Fact]
public async Task Get_MissingSecret_ReturnsNull_AuditsFailure()
{
var (resolver, _, audit, _, _) = NewSut();
string? value = await resolver.GetAsync(new SecretName("sql/absent"), CancellationToken.None);
Assert.Null(value);
AuditEvent evt = Assert.Single(audit.Events);
Assert.Equal(AuditOutcome.Failure, evt.Outcome);
Assert.Contains("not-found", evt.Action, StringComparison.Ordinal);
}
[Fact]
public async Task Get_TombstonedSecret_ReturnsNull()
{
var (resolver, store, audit, cipher, _) = NewSut();
var name = new SecretName("sql/dead");
store.Seed(MakeRow(cipher, name, "hunter2", deleted: true));
string? value = await resolver.GetAsync(name, CancellationToken.None);
Assert.Null(value);
AuditEvent evt = Assert.Single(audit.Events);
Assert.Equal(AuditOutcome.Failure, evt.Outcome);
}
[Fact]
public async Task Get_DecryptionFailure_AuditsFailure_AndRethrows()
{
// The resolver's cipher and the encrypting cipher share a KekId ("k1") but hold different
// random key bytes, so the DEK unwrap fails closed with a SecretDecryptionException.
var store = new CountingSecretStore();
var audit = new CapturingAuditWriter();
var clock = new MutableTimeProvider(DateTimeOffset.UnixEpoch);
var resolverCipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("k1"));
var resolver = new DefaultSecretResolver(store, resolverCipher, audit, Ttl, actorAccessor: null, clock);
var foreignCipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("k1")); // same id, different key
var name = new SecretName("sql/foo");
store.Seed(MakeRow(foreignCipher, name, "hunter2"));
// Fail-loud: the integrity failure must propagate, not be masked as a benign miss/null.
await Assert.ThrowsAsync<SecretDecryptionException>(() => resolver.GetAsync(name, CancellationToken.None));
AuditEvent evt = Assert.Single(audit.Events);
Assert.Equal(AuditOutcome.Failure, evt.Outcome);
Assert.Equal("secret.resolve.decryption-failed", evt.Action);
Assert.Equal("sql/foo", evt.Target);
// Hard guarantee: the plaintext must NEVER appear in any serialized audit field.
foreach (AuditEvent captured in audit.Events)
{
string json = JsonSerializer.Serialize(captured);
Assert.DoesNotContain("hunter2", json, StringComparison.Ordinal);
}
}
[Fact]
public async Task Invalidate_RemovesCacheEntry()
{
var (resolver, store, _, cipher, _) = NewSut();
var name = new SecretName("sql/foo");
store.Seed(MakeRow(cipher, name, "hunter2"));
await resolver.GetAsync(name, CancellationToken.None); // caches (count 1)
resolver.Invalidate(name);
await resolver.GetAsync(name, CancellationToken.None); // cache gone → reload (count 2)
Assert.Equal(2, store.GetCount);
}
[Fact]
public async Task Invalidate_ViaCacheInvalidatorSeam_ForcesReload()
{
var (resolver, store, _, cipher, _) = NewSut();
var name = new SecretName("sql/foo");
store.Seed(MakeRow(cipher, name, "hunter2"));
// Exercise the SAME seam the write path (Ui rotate/delete) uses: ISecretCacheInvalidator.
ISecretCacheInvalidator invalidator = resolver;
await resolver.GetAsync(name, CancellationToken.None); // caches (count 1)
invalidator.Invalidate(name);
await resolver.GetAsync(name, CancellationToken.None); // cache evicted → hits the store again
Assert.Equal(2, store.GetCount);
}
[Fact]
public async Task Get_DefaultActorIsSystem_WhenNoAccessor()
{
var (resolver, store, audit, cipher, _) = NewSut(actor: null);
var name = new SecretName("sql/foo");
store.Seed(MakeRow(cipher, name, "hunter2"));
await resolver.GetAsync(name, CancellationToken.None);
Assert.Equal("system", Assert.Single(audit.Events).Actor);
}
[Fact]
public async Task Get_UsesActorFromAccessor()
{
var (resolver, store, audit, cipher, _) = NewSut(new FixedActorAccessor("alice"));
var name = new SecretName("sql/foo");
store.Seed(MakeRow(cipher, name, "hunter2"));
await resolver.GetAsync(name, CancellationToken.None);
Assert.Equal("alice", Assert.Single(audit.Events).Actor);
}
}
@@ -0,0 +1,143 @@
using System.Security.Cryptography;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.DependencyInjection;
using ZB.MOM.WW.Secrets.Sqlite;
namespace ZB.MOM.WW.Secrets.Tests.DependencyInjection;
/// <summary>
/// Verifies that <see cref="SecretsServiceCollectionExtensions.AddZbSecrets"/> wires the whole
/// core: every seam resolves, a real secret round-trips through the composed graph, and a missing
/// master key fails closed.
/// </summary>
public sealed class AddZbSecretsTests
{
private static string NewTempDbPath() =>
Path.Combine(Path.GetTempPath(), $"zb-secrets-di-{Guid.NewGuid():N}.db");
private static string Base64Key32() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
private static IConfiguration BuildConfig(string sqlitePath, string envVarName) =>
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Secrets:SqlitePath"] = sqlitePath,
["Secrets:MasterKey:Source"] = "Environment",
["Secrets:MasterKey:EnvVarName"] = envVarName,
})
.Build();
[Fact]
public void Registers_All_CoreServices()
{
string dbPath = NewTempDbPath();
string envVar = $"ZB_TEST_KEY_{Guid.NewGuid():N}";
Environment.SetEnvironmentVariable(envVar, Base64Key32());
try
{
var services = new ServiceCollection();
services.AddZbSecrets(BuildConfig(dbPath, envVar), "Secrets");
using ServiceProvider provider = services.BuildServiceProvider();
Assert.NotNull(provider.GetService<ISecretResolver>());
Assert.NotNull(provider.GetService<ISecretStore>());
Assert.NotNull(provider.GetService<IMasterKeyProvider>());
Assert.NotNull(provider.GetService<ISecretCipher>());
ISecretReplicator? replicator = provider.GetService<ISecretReplicator>();
Assert.NotNull(replicator);
Assert.IsType<NoOpSecretReplicator>(replicator);
}
finally
{
Environment.SetEnvironmentVariable(envVar, null);
File.Delete(dbPath);
}
}
[Fact]
public void CacheInvalidator_And_Resolver_AreSameSingletonInstance()
{
string dbPath = NewTempDbPath();
string envVar = $"ZB_TEST_KEY_{Guid.NewGuid():N}";
Environment.SetEnvironmentVariable(envVar, Base64Key32());
try
{
var services = new ServiceCollection();
services.AddZbSecrets(BuildConfig(dbPath, envVar), "Secrets");
using ServiceProvider provider = services.BuildServiceProvider();
var resolver = provider.GetService<ISecretResolver>();
var invalidator = provider.GetService<ISecretCacheInvalidator>();
Assert.NotNull(resolver);
Assert.NotNull(invalidator);
// Same object: an invalidation MUST hit the exact cache the resolver reads from.
Assert.Same(resolver, invalidator);
}
finally
{
Environment.SetEnvironmentVariable(envVar, null);
File.Delete(dbPath);
}
}
[Fact]
public async Task Resolver_Works_EndToEnd_ThroughDi()
{
string dbPath = NewTempDbPath();
string envVar = $"ZB_TEST_KEY_{Guid.NewGuid():N}";
Environment.SetEnvironmentVariable(envVar, Base64Key32());
try
{
var services = new ServiceCollection();
services.AddZbSecrets(BuildConfig(dbPath, envVar), "Secrets");
using ServiceProvider provider = services.BuildServiceProvider();
// Run the migration first so the schema exists (as the hosted service would on startup).
await provider.GetRequiredService<SqliteSecretsStoreMigrator>().MigrateAsync(CancellationToken.None);
var name = new SecretName("db/password");
const string plaintext = "super-secret-value";
ISecretCipher cipher = provider.GetRequiredService<ISecretCipher>();
ISecretStore store = provider.GetRequiredService<ISecretStore>();
StoredSecret row = cipher.Encrypt(name, plaintext, SecretContentType.Text);
await store.UpsertAsync(row, CancellationToken.None);
ISecretResolver resolver = provider.GetRequiredService<ISecretResolver>();
string? resolved = await resolver.GetAsync(name, CancellationToken.None);
Assert.Equal(plaintext, resolved);
}
finally
{
Environment.SetEnvironmentVariable(envVar, null);
File.Delete(dbPath);
}
}
[Fact]
public void MasterKey_Unavailable_FailsClosed()
{
string dbPath = NewTempDbPath();
string envVar = $"ZB_TEST_KEY_{Guid.NewGuid():N}";
// Deliberately do NOT set the env var — the master key cannot be resolved.
try
{
var services = new ServiceCollection();
services.AddZbSecrets(BuildConfig(dbPath, envVar), "Secrets");
using ServiceProvider provider = services.BuildServiceProvider();
IMasterKeyProvider keyProvider = provider.GetRequiredService<IMasterKeyProvider>();
Assert.Throws<MasterKeyUnavailableException>(() => keyProvider.GetMasterKey());
}
finally
{
File.Delete(dbPath);
}
}
}
@@ -0,0 +1,24 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.Secrets.Tests.Fakes;
/// <summary>
/// An <see cref="IAuditWriter"/> test double that collects every <see cref="AuditEvent"/> written,
/// so a test can assert on the audit trail (outcome, target, and — critically — that no plaintext
/// ever appears in any field).
/// </summary>
public sealed class CapturingAuditWriter : IAuditWriter
{
private readonly ConcurrentQueue<AuditEvent> _events = new();
/// <summary>The events captured so far, in write order.</summary>
public IReadOnlyList<AuditEvent> Events => _events.ToArray();
/// <inheritdoc />
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
_events.Enqueue(evt);
return Task.CompletedTask;
}
}
@@ -0,0 +1,51 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Tests.Fakes;
/// <summary>
/// An in-memory <see cref="ISecretStore"/> test double that counts <see cref="GetAsync"/> calls so
/// a test can prove a resolver cache hit (or miss). Only the read path exercised by
/// <c>DefaultSecretResolver</c> is meaningfully implemented; the mutation / replication members
/// throw so an accidental dependency on them fails loudly rather than silently no-ops.
/// </summary>
public sealed class CountingSecretStore : ISecretStore
{
private readonly ConcurrentDictionary<string, StoredSecret> _rows = new(StringComparer.Ordinal);
private int _getCount;
/// <summary>The number of times <see cref="GetAsync"/> has been invoked.</summary>
public int GetCount => Volatile.Read(ref _getCount);
/// <summary>Seeds (or overwrites) a row keyed by its normalized name.</summary>
/// <param name="row">The encrypted row to seed.</param>
public void Seed(StoredSecret row) => _rows[row.Name.Value] = row;
/// <inheritdoc />
public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct)
{
Interlocked.Increment(ref _getCount);
_rows.TryGetValue(name.Value, out StoredSecret? row);
return Task.FromResult(row);
}
/// <inheritdoc />
public Task UpsertAsync(StoredSecret row, CancellationToken ct) =>
throw new NotSupportedException();
/// <inheritdoc />
public Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct) =>
throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) =>
throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) =>
throw new NotSupportedException();
/// <inheritdoc />
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) =>
throw new NotSupportedException();
}
@@ -0,0 +1,45 @@
using System.Security.Cryptography;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Tests.Fakes;
/// <summary>
/// A hand-written <see cref="IMasterKeyProvider"/> test double (the family does not use Moq).
/// Generates a random 32-byte master key once at construction and returns it stably, alongside
/// a caller-supplied <see cref="KekId"/>. Two instances with the same <see cref="KekId"/> string
/// still hold different key bytes, which lets a test prove that a matching KEK id but wrong key
/// material fails closed.
/// </summary>
public sealed class FakeMasterKeyProvider : IMasterKeyProvider
{
private readonly byte[] _key;
/// <summary>
/// Creates a provider with a freshly generated random 32-byte master key and the given
/// <paramref name="kekId"/>.
/// </summary>
/// <param name="kekId">The stable, non-secret key identifier this provider reports.</param>
public FakeMasterKeyProvider(string kekId = "k1")
{
KekId = kekId;
_key = RandomNumberGenerator.GetBytes(32);
}
/// <summary>
/// Creates a provider that reuses an explicit 32-byte key. Use this to build a second
/// provider with the same key bytes as another (or, by omitting it, a different random key).
/// </summary>
/// <param name="kekId">The stable, non-secret key identifier this provider reports.</param>
/// <param name="key">The exact 32-byte master key material to use.</param>
public FakeMasterKeyProvider(string kekId, byte[] key)
{
KekId = kekId;
_key = (byte[])key.Clone();
}
/// <inheritdoc />
public string KekId { get; }
/// <inheritdoc />
public ReadOnlyMemory<byte> GetMasterKey() => _key;
}
@@ -0,0 +1,16 @@
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Tests.Fakes;
/// <summary>
/// An <see cref="ISecretActorAccessor"/> test double that reports a fixed (or <c>null</c>) actor.
/// </summary>
public sealed class FixedActorAccessor : ISecretActorAccessor
{
/// <summary>Creates an accessor that always reports <paramref name="actor"/>.</summary>
/// <param name="actor">The actor to report, or <c>null</c>.</param>
public FixedActorAccessor(string? actor) => CurrentActor = actor;
/// <inheritdoc />
public string? CurrentActor { get; }
}
@@ -0,0 +1,29 @@
namespace ZB.MOM.WW.Secrets.Tests.Fakes;
/// <summary>
/// A <see cref="TimeProvider"/> test double with a settable <see cref="GetUtcNow"/>, so a test can
/// advance the clock past a cache TTL deterministically without sleeping.
/// </summary>
public sealed class MutableTimeProvider : TimeProvider
{
private DateTimeOffset _utcNow;
/// <summary>Creates a provider starting at the given instant (defaults to the Unix epoch).</summary>
/// <param name="start">The initial <see cref="GetUtcNow"/> value.</param>
public MutableTimeProvider(DateTimeOffset? start = null) =>
_utcNow = start ?? DateTimeOffset.UnixEpoch;
/// <summary>Gets or sets the current UTC instant this provider reports.</summary>
public DateTimeOffset UtcNow
{
get => _utcNow;
set => _utcNow = value;
}
/// <summary>Moves the clock forward by <paramref name="delta"/>.</summary>
/// <param name="delta">The amount to advance.</param>
public void Advance(TimeSpan delta) => _utcNow += delta;
/// <inheritdoc />
public override DateTimeOffset GetUtcNow() => _utcNow;
}
@@ -0,0 +1,229 @@
using System.Security.Cryptography;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.MasterKey;
namespace ZB.MOM.WW.Secrets.Tests.MasterKey;
public class MasterKeyProviderTests
{
// ---- Environment ----------------------------------------------------------------------
[Fact]
public void Environment_ReturnsDecodedKey()
{
byte[] key = RandomNumberGenerator.GetBytes(32);
string envVar = UniqueEnvVarName();
Environment.SetEnvironmentVariable(envVar, Convert.ToBase64String(key));
try
{
var provider = new EnvironmentMasterKeyProvider(new MasterKeyOptions { EnvVarName = envVar });
Assert.True(key.AsSpan().SequenceEqual(provider.GetMasterKey().Span));
}
finally
{
Environment.SetEnvironmentVariable(envVar, null);
}
}
[Fact]
public void Environment_KekId_IsStableAcrossCalls()
{
byte[] key = RandomNumberGenerator.GetBytes(32);
string envVar = UniqueEnvVarName();
Environment.SetEnvironmentVariable(envVar, Convert.ToBase64String(key));
try
{
var provider = new EnvironmentMasterKeyProvider(new MasterKeyOptions { EnvVarName = envVar });
string first = provider.KekId;
string second = provider.KekId;
Assert.Equal(first, second);
Assert.StartsWith("sha256:", first, StringComparison.Ordinal);
}
finally
{
Environment.SetEnvironmentVariable(envVar, null);
}
}
[Fact]
public void Environment_ShortKey_Throws()
{
byte[] shortKey = RandomNumberGenerator.GetBytes(16);
string envVar = UniqueEnvVarName();
Environment.SetEnvironmentVariable(envVar, Convert.ToBase64String(shortKey));
try
{
var provider = new EnvironmentMasterKeyProvider(new MasterKeyOptions { EnvVarName = envVar });
Assert.Throws<MasterKeyUnavailableException>(() => provider.GetMasterKey());
}
finally
{
Environment.SetEnvironmentVariable(envVar, null);
}
}
[Fact]
public void Environment_MissingVariable_Throws()
{
string envVar = UniqueEnvVarName();
Environment.SetEnvironmentVariable(envVar, null); // ensure absent
var provider = new EnvironmentMasterKeyProvider(new MasterKeyOptions { EnvVarName = envVar });
Assert.Throws<MasterKeyUnavailableException>(() => provider.GetMasterKey());
}
// ---- File -----------------------------------------------------------------------------
[Fact]
public void File_Raw32Bytes_ReturnsKey()
{
byte[] key = RandomNumberGenerator.GetBytes(32);
string path = Path.GetTempFileName();
File.WriteAllBytes(path, key);
try
{
var provider = new FileMasterKeyProvider(new MasterKeyOptions { FilePath = path });
Assert.True(key.AsSpan().SequenceEqual(provider.GetMasterKey().Span));
}
finally
{
File.Delete(path);
}
}
[Fact]
public void File_Base64Text_ReturnsKey()
{
byte[] key = RandomNumberGenerator.GetBytes(32);
string path = Path.GetTempFileName();
File.WriteAllText(path, Convert.ToBase64String(key) + "\n");
try
{
var provider = new FileMasterKeyProvider(new MasterKeyOptions { FilePath = path });
Assert.True(key.AsSpan().SequenceEqual(provider.GetMasterKey().Span));
}
finally
{
File.Delete(path);
}
}
[Fact]
public void File_TenBytes_Throws()
{
// 10 raw bytes: not 32, and not valid base64 text either → fail closed.
string path = Path.GetTempFileName();
File.WriteAllBytes(path, RandomNumberGenerator.GetBytes(10));
try
{
var provider = new FileMasterKeyProvider(new MasterKeyOptions { FilePath = path });
Assert.Throws<MasterKeyUnavailableException>(() => provider.GetMasterKey());
}
finally
{
File.Delete(path);
}
}
[Fact]
public void File_MissingFile_Throws()
{
var provider = new FileMasterKeyProvider(
new MasterKeyOptions { FilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")) });
Assert.Throws<MasterKeyUnavailableException>(() => provider.GetMasterKey());
}
// ---- KekId derivation -----------------------------------------------------------------
[Fact]
public void ExplicitKekId_IsUsedVerbatim()
{
byte[] key = RandomNumberGenerator.GetBytes(32);
string path = Path.GetTempFileName();
File.WriteAllBytes(path, key);
try
{
var provider = new FileMasterKeyProvider(
new MasterKeyOptions { FilePath = path, KekId = "prod-kek-2026" });
Assert.Equal("prod-kek-2026", provider.KekId);
}
finally
{
File.Delete(path);
}
}
[Fact]
public void DerivedKekId_StartsWithSha256()
{
byte[] key = RandomNumberGenerator.GetBytes(32);
string path = Path.GetTempFileName();
File.WriteAllBytes(path, key);
try
{
var provider = new FileMasterKeyProvider(new MasterKeyOptions { FilePath = path });
Assert.StartsWith("sha256:", provider.KekId, StringComparison.Ordinal);
}
finally
{
File.Delete(path);
}
}
// ---- Factory --------------------------------------------------------------------------
[Fact]
public void Factory_Environment_ReturnsEnvironmentProvider()
{
IMasterKeyProvider provider = MasterKeyProviderFactory.Create(
new MasterKeyOptions { Source = MasterKeySource.Environment });
Assert.IsType<EnvironmentMasterKeyProvider>(provider);
}
[Fact]
public void Factory_File_ReturnsFileProvider()
{
IMasterKeyProvider provider = MasterKeyProviderFactory.Create(
new MasterKeyOptions { Source = MasterKeySource.File, FilePath = "unused" });
Assert.IsType<FileMasterKeyProvider>(provider);
}
// ---- DPAPI (Windows-only) -------------------------------------------------------------
[SkippableFact]
public void Dpapi_RoundTripsOnWindows()
{
Skip.IfNot(OperatingSystem.IsWindows(), "DPAPI is only supported on Windows.");
byte[] key = RandomNumberGenerator.GetBytes(32);
#pragma warning disable CA1416 // Whole body is guarded by Skip.IfNot(IsWindows) above.
byte[] blob = ProtectedData.Protect(key, optionalEntropy: null, DataProtectionScope.CurrentUser);
string path = Path.GetTempFileName();
File.WriteAllBytes(path, blob);
try
{
var provider = new DpapiMasterKeyProvider(new MasterKeyOptions { FilePath = path });
Assert.True(key.AsSpan().SequenceEqual(provider.GetMasterKey().Span));
}
finally
{
File.Delete(path);
}
#pragma warning restore CA1416
}
private static string UniqueEnvVarName() => "ZB_SECRETS_TEST_" + Guid.NewGuid().ToString("N");
}
@@ -0,0 +1,39 @@
using ZB.MOM.WW.Secrets.Abstractions;
using Xunit;
namespace ZB.MOM.WW.Secrets.Tests;
public class SecretNameTests
{
[Theory]
[InlineData("sql/historiangw/historian-password", "sql/historiangw/historian-password")]
[InlineData(" SQL/Foo ", "sql/foo")] // trimmed + lowercased
public void Normalizes(string input, string expected)
=> Assert.Equal(expected, new SecretName(input).Value);
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("bad name with spaces")]
[InlineData("../escape")]
public void RejectsInvalid(string input)
=> Assert.Throws<ArgumentException>(() => new SecretName(input));
[Theory]
[InlineData("/etc/passwd")] // rooted / absolute
[InlineData("/")] // rooted (starts and ends with '/')
[InlineData("sql//foo")] // empty path segment
[InlineData("sql/foo/")] // trailing '/'
public void RejectsRootedOrEmptySegment(string input)
=> Assert.Throws<ArgumentException>(() => new SecretName(input));
[Fact]
public void DefaultInstance_ThrowsOnValue()
=> Assert.Throws<InvalidOperationException>(() => _ = default(SecretName).Value);
[Fact]
public void ValidNestedName_RoundTrips()
=> Assert.Equal(
"sql/historiangw/historian-password",
new SecretName("sql/historiangw/historian-password").Value);
}
@@ -0,0 +1,227 @@
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Sqlite;
namespace ZB.MOM.WW.Secrets.Tests.Sqlite;
public sealed class SqliteSecretStoreTests : IAsyncLifetime, IDisposable
{
private readonly string _dbPath =
Path.Combine(Path.GetTempPath(), $"zb-secrets-store-{Guid.NewGuid():N}.db");
private readonly SecretsSqliteConnectionFactory _factory;
private readonly SqliteSecretStore _store;
public SqliteSecretStoreTests()
{
_factory = new SecretsSqliteConnectionFactory(_dbPath);
_store = new SqliteSecretStore(_factory);
}
public async Task InitializeAsync() =>
await new SqliteSecretsStoreMigrator(_factory).MigrateAsync(CancellationToken.None);
public Task DisposeAsync() => Task.CompletedTask;
private static StoredSecret MakeSecret(
string name,
long revision = 0,
byte[]? ciphertext = null,
DateTimeOffset? updatedUtc = null,
DateTimeOffset? createdUtc = null,
bool isDeleted = false,
DateTimeOffset? deletedUtc = null,
string? createdBy = "alice",
string? updatedBy = "alice") => new()
{
Name = new SecretName(name),
Description = "desc",
ContentType = SecretContentType.ConnectionString,
Ciphertext = ciphertext ?? [1, 2, 3],
Nonce = [4, 5, 6],
Tag = [7, 8, 9],
WrappedDek = [10, 11, 12],
WrapNonce = [13, 14, 15],
WrapTag = [16, 17, 18],
KekId = "kek-1",
Revision = revision,
IsDeleted = isDeleted,
DeletedUtc = deletedUtc,
CreatedUtc = createdUtc ?? DateTimeOffset.UtcNow,
UpdatedUtc = updatedUtc ?? DateTimeOffset.UtcNow,
CreatedBy = createdBy,
UpdatedBy = updatedBy,
};
[Fact]
public async Task Upsert_Then_Get_RoundTrips()
{
StoredSecret row = MakeSecret("app/db-conn");
await _store.UpsertAsync(row, CancellationToken.None);
StoredSecret? got = await _store.GetAsync(new SecretName("app/db-conn"), CancellationToken.None);
Assert.NotNull(got);
Assert.Equal("app/db-conn", got!.Name.Value);
Assert.Equal("desc", got.Description);
Assert.Equal(SecretContentType.ConnectionString, got.ContentType);
Assert.Equal(new byte[] { 1, 2, 3 }, got.Ciphertext);
Assert.Equal(new byte[] { 4, 5, 6 }, got.Nonce);
Assert.Equal(new byte[] { 7, 8, 9 }, got.Tag);
Assert.Equal(new byte[] { 10, 11, 12 }, got.WrappedDek);
Assert.Equal(new byte[] { 13, 14, 15 }, got.WrapNonce);
Assert.Equal(new byte[] { 16, 17, 18 }, got.WrapTag);
Assert.Equal("kek-1", got.KekId);
Assert.Equal(0, got.Revision);
Assert.False(got.IsDeleted);
Assert.Null(got.DeletedUtc);
Assert.Equal("alice", got.CreatedBy);
Assert.Equal("alice", got.UpdatedBy);
}
[Fact]
public async Task Get_ReturnsNull_WhenAbsent()
{
StoredSecret? got = await _store.GetAsync(new SecretName("nope"), CancellationToken.None);
Assert.Null(got);
}
[Fact]
public async Task Upsert_Existing_OverwritesInPlace_BumpsRevision()
{
StoredSecret first = MakeSecret("app/rotating", ciphertext: [1, 1, 1], createdBy: "alice", updatedBy: "alice");
await _store.UpsertAsync(first, CancellationToken.None);
StoredSecret afterFirst = (await _store.GetAsync(new SecretName("app/rotating"), CancellationToken.None))!;
Assert.Equal(0, afterFirst.Revision);
DateTimeOffset originalCreatedUtc = afterFirst.CreatedUtc;
// Second write: different crypto bytes and a different actor.
StoredSecret second = MakeSecret("app/rotating", ciphertext: [9, 9, 9], createdBy: "bob", updatedBy: "bob");
await _store.UpsertAsync(second, CancellationToken.None);
StoredSecret afterSecond = (await _store.GetAsync(new SecretName("app/rotating"), CancellationToken.None))!;
Assert.Equal(1, afterSecond.Revision);
Assert.Equal(new byte[] { 9, 9, 9 }, afterSecond.Ciphertext);
// created_utc / created_by are preserved from the original insert.
Assert.Equal(originalCreatedUtc, afterSecond.CreatedUtc);
Assert.Equal("alice", afterSecond.CreatedBy);
// updated_by reflects the second write.
Assert.Equal("bob", afterSecond.UpdatedBy);
Assert.False(afterSecond.IsDeleted);
}
[Fact]
public async Task List_ExcludesTombstoned_ByDefault()
{
await _store.UpsertAsync(MakeSecret("keep"), CancellationToken.None);
await _store.UpsertAsync(MakeSecret("gone"), CancellationToken.None);
await _store.DeleteAsync(new SecretName("gone"), "carol", CancellationToken.None);
IReadOnlyList<SecretMetadata> visible = await _store.ListAsync(includeDeleted: false, CancellationToken.None);
Assert.DoesNotContain(visible, m => m.Name.Value == "gone");
Assert.Contains(visible, m => m.Name.Value == "keep");
IReadOnlyList<SecretMetadata> all = await _store.ListAsync(includeDeleted: true, CancellationToken.None);
Assert.Contains(all, m => m.Name.Value == "gone");
Assert.Contains(all, m => m.Name.Value == "keep");
// Compile-time proof the projection carries no byte[] members: SecretMetadata is the element type.
SecretMetadata sample = all[0];
Assert.NotNull(sample);
}
[Fact]
public async Task Delete_SetsTombstone_BumpsRevision_ReturnsFalseWhenAbsent()
{
await _store.UpsertAsync(MakeSecret("app/secret"), CancellationToken.None);
bool first = await _store.DeleteAsync(new SecretName("app/secret"), "carol", CancellationToken.None);
Assert.True(first);
StoredSecret tombstoned = (await _store.GetAsync(new SecretName("app/secret"), CancellationToken.None))!;
Assert.True(tombstoned.IsDeleted);
Assert.NotNull(tombstoned.DeletedUtc);
Assert.Equal(1, tombstoned.Revision);
Assert.Equal("carol", tombstoned.UpdatedBy);
// Deleting an already-tombstoned row returns false.
bool second = await _store.DeleteAsync(new SecretName("app/secret"), "carol", CancellationToken.None);
Assert.False(second);
// Deleting an unknown name returns false.
bool unknown = await _store.DeleteAsync(new SecretName("never-existed"), "carol", CancellationToken.None);
Assert.False(unknown);
}
[Fact]
public async Task GetManifest_ReturnsAllRows()
{
await _store.UpsertAsync(MakeSecret("a"), CancellationToken.None);
await _store.UpsertAsync(MakeSecret("b"), CancellationToken.None);
await _store.DeleteAsync(new SecretName("b"), "carol", CancellationToken.None);
IReadOnlyList<SecretManifestEntry> manifest = await _store.GetManifestAsync(CancellationToken.None);
Assert.Equal(2, manifest.Count);
SecretManifestEntry a = manifest.Single(e => e.Name.Value == "a");
SecretManifestEntry b = manifest.Single(e => e.Name.Value == "b");
Assert.False(a.IsDeleted);
Assert.Equal(0, a.Revision);
Assert.True(b.IsDeleted);
Assert.Equal(1, b.Revision);
}
[Fact]
public async Task ApplyReplicated_AppliesNewer_IgnoresStale()
{
DateTimeOffset t1 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
DateTimeOffset t2 = new(2026, 1, 2, 0, 0, 0, TimeSpan.Zero);
DateTimeOffset t3 = new(2026, 1, 3, 0, 0, 0, TimeSpan.Zero);
// Seed a local row at revision 5 / T2.
StoredSecret seed = MakeSecret("repl", revision: 5, ciphertext: [5, 5, 5], updatedUtc: t2, createdUtc: t1);
await _store.ApplyReplicatedAsync(seed, CancellationToken.None);
StoredSecret afterSeed = (await _store.GetAsync(new SecretName("repl"), CancellationToken.None))!;
Assert.Equal(5, afterSeed.Revision);
// Newer row (revision 6 / T3) is applied verbatim (revision NOT bumped past 6).
StoredSecret newer = MakeSecret("repl", revision: 6, ciphertext: [6, 6, 6], updatedUtc: t3, createdUtc: t1);
await _store.ApplyReplicatedAsync(newer, CancellationToken.None);
StoredSecret afterNewer = (await _store.GetAsync(new SecretName("repl"), CancellationToken.None))!;
Assert.Equal(6, afterNewer.Revision);
Assert.Equal(new byte[] { 6, 6, 6 }, afterNewer.Ciphertext);
Assert.Equal(t3, afterNewer.UpdatedUtc);
// Stale row (revision 4 / T1) is ignored.
StoredSecret stale = MakeSecret("repl", revision: 4, ciphertext: [4, 4, 4], updatedUtc: t1, createdUtc: t1);
await _store.ApplyReplicatedAsync(stale, CancellationToken.None);
StoredSecret afterStale = (await _store.GetAsync(new SecretName("repl"), CancellationToken.None))!;
Assert.Equal(6, afterStale.Revision);
Assert.Equal(new byte[] { 6, 6, 6 }, afterStale.Ciphertext);
}
public void Dispose()
{
// Drop pooled connections so the WAL/-shm/-wal sidecars release before we delete.
SqliteConnection.ClearAllPools();
foreach (string path in new[] { _dbPath, _dbPath + "-wal", _dbPath + "-shm" })
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch (IOException)
{
// Best-effort temp cleanup; a leaked temp file is not a test failure.
}
}
}
}
@@ -0,0 +1,127 @@
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Sqlite;
namespace ZB.MOM.WW.Secrets.Tests.Sqlite;
public sealed class SqliteSecretsStoreMigratorTests : IDisposable
{
private readonly string _dbPath =
Path.Combine(Path.GetTempPath(), $"zb-secrets-migrator-{Guid.NewGuid():N}.db");
[Fact]
public async Task Migrate_IsIdempotent()
{
var factory = new SecretsSqliteConnectionFactory(_dbPath);
var migrator = new SqliteSecretsStoreMigrator(factory);
// Running twice must not throw and must leave the schema version at CurrentVersion.
await migrator.MigrateAsync(CancellationToken.None);
await migrator.MigrateAsync(CancellationToken.None);
long version = await ReadSchemaVersionAsync(factory);
Assert.Equal(SqliteSecretsSchema.CurrentVersion, version);
Assert.Equal(1, version);
}
[Fact]
public async Task Migrate_CreatesSecretTableWithExpectedColumns()
{
var factory = new SecretsSqliteConnectionFactory(_dbPath);
var migrator = new SqliteSecretsStoreMigrator(factory);
await migrator.MigrateAsync(CancellationToken.None);
HashSet<string> columns = await ReadSecretColumnsAsync(factory);
string[] expected =
[
"name", "description", "content_type", "ciphertext", "nonce", "tag",
"wrapped_dek", "wrap_nonce", "wrap_tag", "kek_id", "revision",
"is_deleted", "deleted_utc", "created_utc", "updated_utc",
"created_by", "updated_by",
];
Assert.Equal(17, expected.Length);
Assert.Equal(17, columns.Count);
foreach (string column in expected)
{
Assert.Contains(column, columns);
}
}
[Fact]
public async Task Migrate_RefusesNewerSchema()
{
var factory = new SecretsSqliteConnectionFactory(_dbPath);
// Manually stamp an on-disk version newer than this build supports.
await using (SqliteConnection connection = await factory.OpenConnectionAsync(CancellationToken.None))
{
await using SqliteCommand create = connection.CreateCommand();
create.CommandText = SqliteSecretsSchema.CreateSchemaVersionTable;
await create.ExecuteNonQueryAsync();
await using SqliteCommand insert = connection.CreateCommand();
insert.CommandText = """
INSERT INTO schema_version (id, version, applied_utc)
VALUES (1, $version, $applied_utc);
""";
insert.Parameters.AddWithValue("$version", 999);
insert.Parameters.AddWithValue("$applied_utc", DateTimeOffset.UtcNow.ToString("O"));
await insert.ExecuteNonQueryAsync();
}
var migrator = new SqliteSecretsStoreMigrator(factory);
await Assert.ThrowsAsync<SecretStoreMigrationException>(
() => migrator.MigrateAsync(CancellationToken.None));
}
private static async Task<long> ReadSchemaVersionAsync(SecretsSqliteConnectionFactory factory)
{
await using SqliteConnection connection = await factory.OpenConnectionAsync(CancellationToken.None);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT version FROM schema_version WHERE id = 1;";
object? result = await command.ExecuteScalarAsync();
return Convert.ToInt64(result);
}
private static async Task<HashSet<string>> ReadSecretColumnsAsync(SecretsSqliteConnectionFactory factory)
{
await using SqliteConnection connection = await factory.OpenConnectionAsync(CancellationToken.None);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = $"PRAGMA table_info({SqliteSecretsSchema.SecretTable});";
var columns = new HashSet<string>(StringComparer.Ordinal);
await using SqliteDataReader reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
// PRAGMA table_info columns: cid(0), name(1), type(2), ...
columns.Add(reader.GetString(1));
}
return columns;
}
public void Dispose()
{
// Drop pooled connections so the WAL/-shm/-wal sidecars release before we delete.
SqliteConnection.ClearAllPools();
foreach (string path in new[] { _dbPath, _dbPath + "-wal", _dbPath + "-shm" })
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch (IOException)
{
// Best-effort temp cleanup; a leaked temp file is not a test failure.
}
}
}
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="Xunit.SkippableFact" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.Secrets\ZB.MOM.WW.Secrets.csproj" />
<ProjectReference Include="..\..\src\ZB.MOM.WW.Secrets.Abstractions\ZB.MOM.WW.Secrets.Abstractions.csproj" />
<ProjectReference Include="..\..\src\ZB.MOM.WW.Secrets.Cli\ZB.MOM.WW.Secrets.Cli.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,138 @@
using Bunit;
using Bunit.TestDoubles;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Ui.Components;
using ZB.MOM.WW.Secrets.Ui.Tests.Fakes;
namespace ZB.MOM.WW.Secrets.Ui.Tests;
public class ConfirmDeleteModalTests : TestContext
{
[Fact]
public void ConfirmDeleteModal_IsInPageModal_NoJsDialog()
{
// Strict JS interop mode: any IJSRuntime call (a JS confirm/alert dialog) would throw,
// proving the confirmation is a rendered in-page modal, not a browser dialog.
JSInterop.Mode = JSRuntimeMode.Strict;
var store = new RecordingSecretStore();
var audit = new CapturingAuditWriter();
Services.AddSingleton<ISecretStore>(store);
Services.AddSingleton<IAuditWriter>(audit);
var name = new SecretName("db/primary");
var auth = this.AddTestAuthorization();
auth.SetAuthorized("carol");
auth.SetPolicies(SecretsAuthorization.ManagePolicy);
bool deleted = false;
var cut = RenderComponent<ConfirmDeleteModal>(p => p
.Add(c => c.Visible, true)
.Add(c => c.Name, name)
.Add(c => c.OnDeleted, () => deleted = true));
// The confirmation is a rendered in-page modal element.
Assert.NotEmpty(cut.FindAll(".modal"));
Assert.Contains("db/primary", cut.Markup);
cut.Find("[data-testid=confirm-delete]").Click();
// Confirm tombstoned the row (with the resolved actor) and wrote a secret.delete audit.
Assert.Single(store.Deletes);
Assert.Equal("db/primary", store.Deletes[0].Name.Value);
Assert.Equal("carol", store.Deletes[0].Actor);
Assert.True(deleted);
Assert.Single(audit.Events);
AuditEvent evt = audit.Events[0];
Assert.Equal("secret.delete", evt.Action);
Assert.Equal(AuditOutcome.Success, evt.Outcome);
Assert.Equal("Secrets", evt.Category);
Assert.Equal("db/primary", evt.Target);
Assert.Equal("carol", evt.Actor);
}
[Fact]
public void ConfirmDeleteModal_Cancel_DoesNotDelete()
{
var store = new RecordingSecretStore();
var audit = new CapturingAuditWriter();
Services.AddSingleton<ISecretStore>(store);
Services.AddSingleton<IAuditWriter>(audit);
var name = new SecretName("db/primary");
var auth = this.AddTestAuthorization();
auth.SetAuthorized("carol");
auth.SetPolicies(SecretsAuthorization.ManagePolicy);
bool cancelled = false;
var cut = RenderComponent<ConfirmDeleteModal>(p => p
.Add(c => c.Visible, true)
.Add(c => c.Name, name)
.Add(c => c.OnCancelled, () => cancelled = true));
cut.Find("[data-testid=cancel-delete]").Click();
Assert.True(cancelled);
Assert.Empty(store.Deletes);
Assert.Empty(audit.Events);
}
[Fact]
public void ConfirmDeleteModal_StoreFault_AuditsFailure_ShowsError_DoesNotThrow()
{
var store = new RecordingSecretStore
{
DeleteFault = new MasterKeyUnavailableException("KEK unavailable."),
};
var audit = new CapturingAuditWriter();
Services.AddSingleton<ISecretStore>(store);
Services.AddSingleton<IAuditWriter>(audit);
var name = new SecretName("db/primary");
var auth = this.AddTestAuthorization();
auth.SetAuthorized("carol");
auth.SetPolicies(SecretsAuthorization.ManagePolicy);
bool deleted = false;
var cut = RenderComponent<ConfirmDeleteModal>(p => p
.Add(c => c.Visible, true)
.Add(c => c.Name, name)
.Add(c => c.OnDeleted, () => deleted = true));
// The click must not throw into the Blazor event pipeline.
cut.Find("[data-testid=confirm-delete]").Click();
// OnDeleted not raised; a Failure audit recorded; an error surfaced.
Assert.False(deleted);
Assert.Single(audit.Events);
AuditEvent evt = audit.Events[0];
Assert.Equal("secret.delete", evt.Action);
Assert.Equal(AuditOutcome.Failure, evt.Outcome);
Assert.Equal("Secrets", evt.Category);
Assert.Equal("db/primary", evt.Target);
Assert.Equal("carol", evt.Actor);
Assert.NotEmpty(cut.FindAll("[data-testid=delete-error]"));
}
[Fact]
public void ConfirmDeleteModal_NotVisible_RendersNothing()
{
var store = new RecordingSecretStore();
var audit = new CapturingAuditWriter();
Services.AddSingleton<ISecretStore>(store);
Services.AddSingleton<IAuditWriter>(audit);
var name = new SecretName("db/primary");
this.AddTestAuthorization().SetAuthorized("carol");
var cut = RenderComponent<ConfirmDeleteModal>(p => p
.Add(c => c.Visible, false)
.Add(c => c.Name, name));
Assert.Empty(cut.FindAll(".modal"));
}
}
@@ -0,0 +1,24 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.Secrets.Ui.Tests.Fakes;
/// <summary>
/// An <see cref="IAuditWriter"/> test double that collects every <see cref="AuditEvent"/> written,
/// so a test can assert on the audit trail (action, outcome, target, actor) and — critically — that
/// no plaintext ever appears in any audit field.
/// </summary>
public sealed class CapturingAuditWriter : IAuditWriter
{
private readonly ConcurrentQueue<AuditEvent> _events = new();
/// <summary>The events captured so far, in write order.</summary>
public IReadOnlyList<AuditEvent> Events => _events.ToArray();
/// <inheritdoc />
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
_events.Enqueue(evt);
return Task.CompletedTask;
}
}
@@ -0,0 +1,45 @@
using System.Text;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Ui.Tests.Fakes;
/// <summary>
/// A trivial, reversible <see cref="ISecretCipher"/> stand-in for UI tests. It does not perform real
/// cryptography — it simply carries the UTF-8 plaintext in <see cref="StoredSecret.Ciphertext"/> so a
/// test can prove the editor's encrypt→upsert round-trips back to the entered value. Never used in
/// production. A <see cref="FailDecrypt"/> flag lets a test drive the decrypt-failure reveal path.
/// </summary>
public sealed class FakeCipher : ISecretCipher
{
/// <summary>When <c>true</c>, <see cref="Decrypt"/> throws to exercise the failure path.</summary>
public bool FailDecrypt { get; set; }
/// <inheritdoc />
public StoredSecret Encrypt(SecretName name, string plaintext, SecretContentType contentType)
=> new()
{
Name = name,
ContentType = contentType,
Ciphertext = Encoding.UTF8.GetBytes(plaintext),
Nonce = [1, 2, 3],
Tag = [4, 5, 6],
WrappedDek = [7, 8, 9],
WrapNonce = [10, 11, 12],
WrapTag = [13, 14, 15],
KekId = "fake-kek",
Revision = 0,
CreatedUtc = default,
UpdatedUtc = default,
};
/// <inheritdoc />
public string Decrypt(StoredSecret secret)
{
if (FailDecrypt)
{
throw new SecretDecryptionException("Fake decrypt failure.");
}
return Encoding.UTF8.GetString(secret.Ciphertext);
}
}
@@ -0,0 +1,91 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Ui.Tests.Fakes;
/// <summary>
/// A hand-written <see cref="ISecretStore"/> test double (the family does not use Moq) that keeps
/// an in-memory table of rows and records every <see cref="UpsertAsync"/> and
/// <see cref="DeleteAsync"/> call so a test can assert on the mutation trail.
/// </summary>
public sealed class RecordingSecretStore : ISecretStore
{
private readonly ConcurrentDictionary<string, StoredSecret> _rows = new();
private readonly ConcurrentQueue<StoredSecret> _upserts = new();
private readonly ConcurrentQueue<(SecretName Name, string? Actor)> _deletes = new();
/// <summary>Rows upserted, in call order.</summary>
public IReadOnlyList<StoredSecret> Upserts => _upserts.ToArray();
/// <summary>Delete calls (name + recorded actor), in call order.</summary>
public IReadOnlyList<(SecretName Name, string? Actor)> Deletes => _deletes.ToArray();
/// <summary>When set, <see cref="UpsertAsync"/> throws this to drive the mutation-failure path.</summary>
public Exception? UpsertFault { get; set; }
/// <summary>When set, <see cref="DeleteAsync"/> throws this to drive the mutation-failure path.</summary>
public Exception? DeleteFault { get; set; }
/// <summary>Seeds a row so <see cref="GetAsync"/> and <see cref="ListAsync"/> can return it.</summary>
/// <param name="row">The row to seed.</param>
public void Seed(StoredSecret row) => _rows[row.Name.Value] = row;
/// <inheritdoc />
public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct)
=> Task.FromResult(_rows.TryGetValue(name.Value, out StoredSecret? row) ? row : null);
/// <inheritdoc />
public Task UpsertAsync(StoredSecret row, CancellationToken ct)
{
if (UpsertFault is not null)
{
throw UpsertFault;
}
_rows[row.Name.Value] = row;
_upserts.Enqueue(row);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct)
{
if (DeleteFault is not null)
{
throw DeleteFault;
}
_deletes.Enqueue((name, actor));
return Task.FromResult(_rows.TryRemove(name.Value, out _));
}
/// <inheritdoc />
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct)
{
IReadOnlyList<SecretMetadata> projection = _rows.Values
.Where(r => includeDeleted || !r.IsDeleted)
.Select(r => new SecretMetadata
{
Name = r.Name,
Description = r.Description,
ContentType = r.ContentType,
KekId = r.KekId,
Revision = r.Revision,
IsDeleted = r.IsDeleted,
CreatedUtc = r.CreatedUtc,
UpdatedUtc = r.UpdatedUtc,
CreatedBy = r.CreatedBy,
UpdatedBy = r.UpdatedBy,
})
.ToArray();
return Task.FromResult(projection);
}
/// <inheritdoc />
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct)
=> throw new NotSupportedException();
/// <inheritdoc />
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct)
=> throw new NotSupportedException();
}
@@ -0,0 +1,127 @@
using Bunit;
using Bunit.TestDoubles;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Ui.Components;
using ZB.MOM.WW.Secrets.Ui.Tests.Fakes;
namespace ZB.MOM.WW.Secrets.Ui.Tests;
public class RevealButtonTests : TestContext
{
private const string PlainValue = "the-plaintext-value-42";
private (RecordingSecretStore Store, CapturingAuditWriter Audit, FakeCipher Cipher) RegisterServices()
{
var store = new RecordingSecretStore();
var audit = new CapturingAuditWriter();
var cipher = new FakeCipher();
Services.AddSingleton<ISecretStore>(store);
Services.AddSingleton<IAuditWriter>(audit);
Services.AddSingleton<ISecretCipher>(cipher);
return (store, audit, cipher);
}
private static StoredSecret Row(SecretName name, string plaintext)
=> new FakeCipher().Encrypt(name, plaintext, SecretContentType.Text) with
{
Revision = 1,
CreatedUtc = DateTimeOffset.UnixEpoch,
UpdatedUtc = DateTimeOffset.UnixEpoch,
};
[Fact]
public void RevealButton_ShowsValueOnClick_AndAudits()
{
var (store, audit, _) = RegisterServices();
var name = new SecretName("api/token");
store.Seed(Row(name, PlainValue));
var auth = this.AddTestAuthorization();
auth.SetAuthorized("carol");
auth.SetPolicies(SecretsAuthorization.RevealPolicy);
var cut = RenderComponent<RevealButton>(p => p.Add(c => c.Name, name));
// The value is NOT present before the reveal click.
Assert.DoesNotContain(PlainValue, cut.Markup);
Assert.Empty(audit.Events);
cut.Find("button.reveal-secret").Click();
// After the click the plaintext is shown and a Success reveal audit is recorded.
Assert.Contains(PlainValue, cut.Markup);
Assert.Single(audit.Events);
AuditEvent evt = audit.Events[0];
Assert.Equal("secret.reveal", evt.Action);
Assert.Equal(AuditOutcome.Success, evt.Outcome);
Assert.Equal("Secrets", evt.Category);
Assert.Equal("api/token", evt.Target);
Assert.Equal("carol", evt.Actor);
AssertNoValueLeak(evt);
}
[Fact]
public void RevealButton_MissingSecret_AuditsFailure()
{
var (_, audit, _) = RegisterServices();
var name = new SecretName("api/missing");
var auth = this.AddTestAuthorization();
auth.SetAuthorized("carol");
auth.SetPolicies(SecretsAuthorization.RevealPolicy);
var cut = RenderComponent<RevealButton>(p => p.Add(c => c.Name, name));
cut.Find("button.reveal-secret").Click();
// No value shown; a Failure reveal audit recorded.
Assert.DoesNotContain(PlainValue, cut.Markup);
Assert.Single(audit.Events);
AuditEvent evt = audit.Events[0];
Assert.Equal("secret.reveal", evt.Action);
Assert.Equal(AuditOutcome.Failure, evt.Outcome);
Assert.Equal("api/missing", evt.Target);
}
[Fact]
public void RevealButton_DecryptFault_AuditsFailure_ShowsError_NoValue()
{
var (store, audit, cipher) = RegisterServices();
var name = new SecretName("api/token");
store.Seed(Row(name, PlainValue));
cipher.FailDecrypt = true;
var auth = this.AddTestAuthorization();
auth.SetAuthorized("carol");
auth.SetPolicies(SecretsAuthorization.RevealPolicy);
var cut = RenderComponent<RevealButton>(p => p.Add(c => c.Name, name));
// The click must not throw into the Blazor event pipeline.
cut.Find("button.reveal-secret").Click();
// No value shown, an error surfaced, and a Failure reveal audit recorded (no value leak).
Assert.DoesNotContain(PlainValue, cut.Markup);
Assert.NotEmpty(cut.FindAll("[data-testid=reveal-error]"));
Assert.Single(audit.Events);
AuditEvent evt = audit.Events[0];
Assert.Equal("secret.reveal", evt.Action);
Assert.Equal(AuditOutcome.Failure, evt.Outcome);
Assert.Equal("api/token", evt.Target);
Assert.Equal("carol", evt.Actor);
AssertNoValueLeak(evt);
}
private static void AssertNoValueLeak(AuditEvent evt)
{
foreach (string? field in new[] { evt.Action, evt.Actor, evt.Category, evt.Target, evt.DetailsJson, evt.ToString() })
{
if (field is not null)
{
Assert.DoesNotContain(PlainValue, field, StringComparison.Ordinal);
}
}
}
}
@@ -0,0 +1,184 @@
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Ui.Components;
using ZB.MOM.WW.Secrets.Ui.Tests.Fakes;
namespace ZB.MOM.WW.Secrets.Ui.Tests;
public class SecretEditorTests : TestContext
{
private const string EnteredValue = "sup3r-s3cret-value!";
private (RecordingSecretStore Store, CapturingAuditWriter Audit, FakeCipher Cipher) RegisterServices()
{
var store = new RecordingSecretStore();
var audit = new CapturingAuditWriter();
var cipher = new FakeCipher();
Services.AddSingleton<ISecretStore>(store);
Services.AddSingleton<IAuditWriter>(audit);
Services.AddSingleton<ISecretCipher>(cipher);
return (store, audit, cipher);
}
[Fact]
public void SecretEditor_Add_EncryptsAndUpserts()
{
var (store, audit, cipher) = RegisterServices();
var auth = this.AddTestAuthorization();
auth.SetAuthorized("carol");
auth.SetPolicies(SecretsAuthorization.ManagePolicy);
var cut = RenderComponent<SecretEditor>(p => p
.Add(c => c.OnSaved, () => { }));
// The value control is a masked password input.
var valueInput = cut.Find("[data-testid=secret-value]");
Assert.Equal("password", valueInput.GetAttribute("type"));
cut.Find("[data-testid=secret-name]").Change("app/thing");
cut.Find("[data-testid=secret-description]").Change("A thing");
cut.Find("[data-testid=secret-content-type]").Change(nameof(SecretContentType.Json));
valueInput.Change(EnteredValue);
cut.Find("[data-testid=editor-submit]").Click();
// Exactly one upsert, round-tripping to the entered value under the entered name.
Assert.Single(store.Upserts);
StoredSecret row = store.Upserts[0];
Assert.Equal("app/thing", row.Name.Value);
Assert.Equal(SecretContentType.Json, row.ContentType);
Assert.Equal("A thing", row.Description);
Assert.Equal("carol", row.UpdatedBy);
Assert.Equal("carol", row.CreatedBy);
Assert.Equal(EnteredValue, cipher.Decrypt(row));
// A single secret.add audit was written, Success, correct target/actor, and NO value leak.
Assert.Single(audit.Events);
AuditEvent evt = audit.Events[0];
Assert.Equal("secret.add", evt.Action);
Assert.Equal(AuditOutcome.Success, evt.Outcome);
Assert.Equal("Secrets", evt.Category);
Assert.Equal("app/thing", evt.Target);
Assert.Equal("carol", evt.Actor);
AssertNoValueLeak(evt);
}
[Fact]
public void SecretEditor_Rotate_PreservesName_Overwrites()
{
var (store, audit, cipher) = RegisterServices();
var name = new SecretName("db/primary");
var auth = this.AddTestAuthorization();
auth.SetAuthorized("dave");
auth.SetPolicies(SecretsAuthorization.ManagePolicy);
var cut = RenderComponent<SecretEditor>(p => p
.Add(c => c.ExistingName, name)
.Add(c => c.OnSaved, () => { }));
// In rotate mode the name field is read-only and shows the existing name.
var nameInput = cut.Find("[data-testid=secret-name]");
Assert.True(nameInput.HasAttribute("readonly"));
Assert.Equal("db/primary", nameInput.GetAttribute("value"));
cut.Find("[data-testid=secret-value]").Change("rotated-value");
cut.Find("[data-testid=editor-submit]").Click();
Assert.Single(store.Upserts);
StoredSecret row = store.Upserts[0];
Assert.Equal("db/primary", row.Name.Value);
Assert.Equal("rotated-value", cipher.Decrypt(row));
Assert.Single(audit.Events);
Assert.Equal("secret.rotate", audit.Events[0].Action);
Assert.Equal("db/primary", audit.Events[0].Target);
AssertNoValueLeak(audit.Events[0], "rotated-value");
}
[Fact]
public void SecretEditor_Rotate_PreservesDescriptionAndContentType()
{
var (store, _, cipher) = RegisterServices();
var name = new SecretName("db/primary");
// Seed an existing Json secret with a description; rotating must NOT blank either.
StoredSecret existing = cipher.Encrypt(name, "old-value", SecretContentType.Json) with
{
Description = "db creds",
Revision = 1,
CreatedUtc = DateTimeOffset.UnixEpoch,
UpdatedUtc = DateTimeOffset.UnixEpoch,
};
store.Seed(existing);
var auth = this.AddTestAuthorization();
auth.SetAuthorized("dave");
auth.SetPolicies(SecretsAuthorization.ManagePolicy);
var cut = RenderComponent<SecretEditor>(p => p
.Add(c => c.ExistingName, name)
.Add(c => c.OnSaved, () => { }));
// The editor pre-filled the existing metadata (no plaintext read).
Assert.Equal("db creds", cut.Find("[data-testid=secret-description]").GetAttribute("value"));
// The operator supplies ONLY a new value.
cut.Find("[data-testid=secret-value]").Change("rotated-value");
cut.Find("[data-testid=editor-submit]").Click();
Assert.Single(store.Upserts);
StoredSecret row = store.Upserts[0];
Assert.Equal("db/primary", row.Name.Value);
Assert.Equal("rotated-value", cipher.Decrypt(row));
// Metadata preserved — content type not reset to Text, description not blanked.
Assert.Equal(SecretContentType.Json, row.ContentType);
Assert.Equal("db creds", row.Description);
}
[Fact]
public void SecretEditor_Add_StoreFault_AuditsFailure_ShowsError_DoesNotThrow()
{
var (store, audit, _) = RegisterServices();
store.UpsertFault = new MasterKeyUnavailableException("KEK unavailable.");
var auth = this.AddTestAuthorization();
auth.SetAuthorized("carol");
auth.SetPolicies(SecretsAuthorization.ManagePolicy);
var cut = RenderComponent<SecretEditor>(p => p
.Add(c => c.OnSaved, () => { }));
cut.Find("[data-testid=secret-name]").Change("app/thing");
cut.Find("[data-testid=secret-value]").Change(EnteredValue);
// The click must not throw into the Blazor event pipeline.
cut.Find("[data-testid=editor-submit]").Click();
// Nothing persisted, a Failure audit recorded, an error surfaced, and no value leak.
Assert.Empty(store.Upserts);
Assert.Single(audit.Events);
AuditEvent evt = audit.Events[0];
Assert.Equal("secret.add", evt.Action);
Assert.Equal(AuditOutcome.Failure, evt.Outcome);
Assert.Equal("app/thing", evt.Target);
Assert.Equal("carol", evt.Actor);
AssertNoValueLeak(evt);
Assert.NotEmpty(cut.FindAll("[data-testid=editor-error]"));
}
private static void AssertNoValueLeak(AuditEvent evt, string value = EnteredValue)
{
foreach (string? field in new[] { evt.Action, evt.Actor, evt.Category, evt.Target, evt.DetailsJson, evt.ToString() })
{
if (field is not null)
{
Assert.DoesNotContain(value, field, StringComparison.Ordinal);
}
}
}
}
@@ -0,0 +1,128 @@
using Bunit;
using Bunit.TestDoubles;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Ui;
using ZB.MOM.WW.Secrets.Ui.Tests.Fakes;
namespace ZB.MOM.WW.Secrets.Ui.Tests;
public class SecretsListTests : TestContext
{
private static readonly SecretMetadata SecretOne = new()
{
Name = new SecretName("db/primary"),
Description = "Primary database connection",
ContentType = SecretContentType.ConnectionString,
KekId = "kek-a",
Revision = 3,
CreatedUtc = new DateTimeOffset(2026, 7, 1, 0, 0, 0, TimeSpan.Zero),
UpdatedUtc = new DateTimeOffset(2026, 7, 10, 12, 0, 0, TimeSpan.Zero),
UpdatedBy = "alice",
};
private static readonly SecretMetadata SecretTwo = new()
{
Name = new SecretName("api/token"),
Description = "External API token",
ContentType = SecretContentType.Text,
KekId = "kek-b",
Revision = 1,
CreatedUtc = new DateTimeOffset(2026, 7, 2, 0, 0, 0, TimeSpan.Zero),
UpdatedUtc = new DateTimeOffset(2026, 7, 11, 8, 30, 0, TimeSpan.Zero),
UpdatedBy = "bob",
};
private void RegisterStore(params SecretMetadata[] rows)
{
// SecretsList now embeds RevealButton / ConfirmDeleteModal / SecretEditor, which inject the
// cipher and audit writer, so those seams must be registered even for list-only assertions.
Services.AddSingleton<ISecretStore>(new FakeSecretStore(rows));
Services.AddSingleton<ISecretCipher>(new FakeCipher());
Services.AddSingleton<IAuditWriter>(new CapturingAuditWriter());
}
[Fact]
public void Renders_Metadata_ForEachSecret()
{
RegisterStore(SecretOne, SecretTwo);
var auth = this.AddTestAuthorization();
auth.SetAuthorized("alice");
auth.SetPolicies(SecretsAuthorization.ManagePolicy);
var cut = RenderComponent<SecretsList>();
var markup = cut.Markup;
Assert.Contains("db/primary", markup);
Assert.Contains("Primary database connection", markup);
Assert.Contains(nameof(SecretContentType.ConnectionString), markup);
Assert.Contains("api/token", markup);
Assert.Contains("External API token", markup);
Assert.Contains(nameof(SecretContentType.Text), markup);
}
[Fact]
public void RevealButton_Hidden_WithoutRevealPolicy()
{
RegisterStore(SecretOne, SecretTwo);
var auth = this.AddTestAuthorization();
auth.SetAuthorized("alice");
auth.SetPolicies(SecretsAuthorization.ManagePolicy);
var cut = RenderComponent<SecretsList>();
Assert.Empty(cut.FindAll("button.reveal-secret"));
Assert.DoesNotContain("Reveal", cut.Markup);
}
[Fact]
public void RevealButton_Shown_WithRevealPolicy()
{
RegisterStore(SecretOne, SecretTwo);
var auth = this.AddTestAuthorization();
auth.SetAuthorized("carol");
auth.SetPolicies(SecretsAuthorization.ManagePolicy, SecretsAuthorization.RevealPolicy);
var cut = RenderComponent<SecretsList>();
// One reveal affordance per secret row.
Assert.Equal(2, cut.FindAll("button.reveal-secret").Count);
}
[Fact]
public void EmptyList_ShowsEmptyState()
{
RegisterStore();
var auth = this.AddTestAuthorization();
auth.SetAuthorized("alice");
auth.SetPolicies(SecretsAuthorization.ManagePolicy);
var cut = RenderComponent<SecretsList>();
Assert.Contains("No secrets", cut.Markup);
}
/// <summary>Minimal hand-written <see cref="ISecretStore"/> whose only real behavior is <see cref="ListAsync"/>.</summary>
private sealed class FakeSecretStore(IReadOnlyList<SecretMetadata> rows) : ISecretStore
{
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct)
=> Task.FromResult(rows);
public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct)
=> throw new NotSupportedException();
public Task UpsertAsync(StoredSecret row, CancellationToken ct)
=> throw new NotSupportedException();
public Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct)
=> throw new NotSupportedException();
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct)
=> throw new NotSupportedException();
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct)
=> throw new NotSupportedException();
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="bunit" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="ZB.MOM.WW.Audit" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.Secrets.Ui\ZB.MOM.WW.Secrets.Ui.csproj" />
</ItemGroup>
</Project>