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:
@@ -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>
|
||||||
@@ -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.
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
+22
@@ -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;
|
||||||
|
}
|
||||||
+27
@@ -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;
|
||||||
|
}
|
||||||
+93
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+132
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+143
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+127
@@ -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>
|
||||||
Reference in New Issue
Block a user