From 824facab398491404b2f8babddfb66ca2d178a0b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 16:24:38 -0400 Subject: [PATCH 01/20] feat(secrets): scaffold ZB.MOM.WW.Secrets solution + 4 projects --- ZB.MOM.WW.Secrets/Directory.Build.props | 12 ++++++ ZB.MOM.WW.Secrets/Directory.Packages.props | 41 +++++++++++++++++++ ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.slnx | 12 ++++++ .../AssemblyMarker.cs | 9 ++++ .../ZB.MOM.WW.Secrets.Abstractions.csproj | 22 ++++++++++ .../src/ZB.MOM.WW.Secrets.Cli/Program.cs | 3 ++ .../ZB.MOM.WW.Secrets.Cli.csproj | 15 +++++++ .../ZB.MOM.WW.Secrets.Ui/AssemblyMarker.cs | 9 ++++ .../ZB.MOM.WW.Secrets.Ui.csproj | 38 +++++++++++++++++ .../src/ZB.MOM.WW.Secrets/AssemblyMarker.cs | 9 ++++ .../ZB.MOM.WW.Secrets.csproj | 36 ++++++++++++++++ .../PlaceholderTests.cs | 7 ++++ .../ZB.MOM.WW.Secrets.Tests.csproj | 24 +++++++++++ .../PlaceholderTests.cs | 7 ++++ .../ZB.MOM.WW.Secrets.Ui.Tests.csproj | 23 +++++++++++ 15 files changed, 267 insertions(+) create mode 100644 ZB.MOM.WW.Secrets/Directory.Build.props create mode 100644 ZB.MOM.WW.Secrets/Directory.Packages.props create mode 100644 ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.slnx create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/AssemblyMarker.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ZB.MOM.WW.Secrets.Abstractions.csproj create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Program.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/AssemblyMarker.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/ZB.MOM.WW.Secrets.Ui.csproj create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/AssemblyMarker.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/PlaceholderTests.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/PlaceholderTests.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ZB.MOM.WW.Secrets.Ui.Tests.csproj diff --git a/ZB.MOM.WW.Secrets/Directory.Build.props b/ZB.MOM.WW.Secrets/Directory.Build.props new file mode 100644 index 0000000..c4755a6 --- /dev/null +++ b/ZB.MOM.WW.Secrets/Directory.Build.props @@ -0,0 +1,12 @@ + + + + net10.0 + enable + enable + latest + 0.1.0 + true + + + diff --git a/ZB.MOM.WW.Secrets/Directory.Packages.props b/ZB.MOM.WW.Secrets/Directory.Packages.props new file mode 100644 index 0000000..0fb7b4c --- /dev/null +++ b/ZB.MOM.WW.Secrets/Directory.Packages.props @@ -0,0 +1,41 @@ + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.slnx b/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.slnx new file mode 100644 index 0000000..f74e9c1 --- /dev/null +++ b/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.slnx @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/AssemblyMarker.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/AssemblyMarker.cs new file mode 100644 index 0000000..53d84d4 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/AssemblyMarker.cs @@ -0,0 +1,9 @@ +namespace ZB.MOM.WW.Secrets.Abstractions; + +/// +/// Internal marker giving the assembly a compilable input while the contract +/// surface is scaffolded. Replace with real contracts as they are implemented. +/// +internal static class AssemblyMarker +{ +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ZB.MOM.WW.Secrets.Abstractions.csproj b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ZB.MOM.WW.Secrets.Abstractions.csproj new file mode 100644 index 0000000..4977b7d --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ZB.MOM.WW.Secrets.Abstractions.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + + + + true + ZB.MOM.WW.Secrets.Abstractions + ZB.MOM.WW + Secrets contracts (envelope-encrypted secret storage) for the ZB.MOM.WW SCADA family. + https://gitea.dohertylan.com/dohertj2/zb-mom-ww-secrets + https://gitea.dohertylan.com/dohertj2/zb-mom-ww-secrets + + + + + + + diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Program.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Program.cs new file mode 100644 index 0000000..44ac559 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Program.cs @@ -0,0 +1,3 @@ +// Placeholder entry point for the ZB.MOM.WW.Secrets CLI. Real commands are added +// in a later task; for now it is a no-op that exits successfully. +return 0; diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj new file mode 100644 index 0000000..063ae94 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/AssemblyMarker.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/AssemblyMarker.cs new file mode 100644 index 0000000..4c5bad5 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/AssemblyMarker.cs @@ -0,0 +1,9 @@ +namespace ZB.MOM.WW.Secrets.Ui; + +/// +/// Internal marker giving the assembly a compilable input while the Blazor +/// components are scaffolded. Replace with real components as they are added. +/// +internal static class AssemblyMarker +{ +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/ZB.MOM.WW.Secrets.Ui.csproj b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/ZB.MOM.WW.Secrets.Ui.csproj new file mode 100644 index 0000000..c3b2a71 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/ZB.MOM.WW.Secrets.Ui.csproj @@ -0,0 +1,38 @@ + + + + ZB.MOM.WW.Secrets.Ui + true + + true + $(NoWarn);CS1591 + true + ZB.MOM.WW.Secrets.Ui + ZB.MOM.WW + Blazor secrets-management UI (Technical-Light) for the ZB.MOM.WW SCADA family. + https://gitea.dohertylan.com/dohertj2/zb-mom-ww-secrets + https://gitea.dohertylan.com/dohertj2/zb-mom-ww-secrets + + + + + + + + + + + + + + + + + + + + diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/AssemblyMarker.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/AssemblyMarker.cs new file mode 100644 index 0000000..4217325 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/AssemblyMarker.cs @@ -0,0 +1,9 @@ +namespace ZB.MOM.WW.Secrets; + +/// +/// Internal marker giving the assembly a compilable input while the store and +/// resolver are scaffolded. Replace with real implementation as it is added. +/// +internal static class AssemblyMarker +{ +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj new file mode 100644 index 0000000..2776ae5 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + net10.0 + enable + enable + + + + true + ZB.MOM.WW.Secrets + ZB.MOM.WW + Envelope-encrypted (AES-256-GCM) secrets store + resolver for the ZB.MOM.WW SCADA family. + https://gitea.dohertylan.com/dohertj2/zb-mom-ww-secrets + https://gitea.dohertylan.com/dohertj2/zb-mom-ww-secrets + + + + + + + diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/PlaceholderTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/PlaceholderTests.cs new file mode 100644 index 0000000..8464965 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/PlaceholderTests.cs @@ -0,0 +1,7 @@ +namespace ZB.MOM.WW.Secrets.Tests; + +public class PlaceholderTests +{ + [Fact] + public void Builds() => Assert.True(true); +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj new file mode 100644 index 0000000..89c1e1b --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj @@ -0,0 +1,24 @@ + + + + false + + + + + + + + + + + + + + + + + + + + diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/PlaceholderTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/PlaceholderTests.cs new file mode 100644 index 0000000..24ecffb --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/PlaceholderTests.cs @@ -0,0 +1,7 @@ +namespace ZB.MOM.WW.Secrets.Ui.Tests; + +public class PlaceholderTests +{ + [Fact] + public void Builds() => Assert.True(true); +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ZB.MOM.WW.Secrets.Ui.Tests.csproj b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ZB.MOM.WW.Secrets.Ui.Tests.csproj new file mode 100644 index 0000000..a6d9697 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ZB.MOM.WW.Secrets.Ui.Tests.csproj @@ -0,0 +1,23 @@ + + + + false + + + + + + + + + + + + + + + + + + + From d78b53304545d10af414dc1271fb304af1b45bbc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 16:28:11 -0400 Subject: [PATCH 02/20] feat(secrets): abstractions value types, enums, exceptions --- .../AssemblyMarker.cs | 9 --- .../SecretContentType.cs | 20 ++++++ .../SecretManifestEntry.cs | 21 +++++++ .../SecretMetadata.cs | 39 ++++++++++++ .../SecretName.cs | 63 +++++++++++++++++++ .../SecretsExceptions.cs | 13 ++++ .../StoredSecret.cs | 61 ++++++++++++++++++ .../SecretNameTests.cs | 21 +++++++ 8 files changed, 238 insertions(+), 9 deletions(-) delete mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/AssemblyMarker.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretContentType.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretManifestEntry.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretMetadata.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretName.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretsExceptions.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/StoredSecret.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/SecretNameTests.cs diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/AssemblyMarker.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/AssemblyMarker.cs deleted file mode 100644 index 53d84d4..0000000 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/AssemblyMarker.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace ZB.MOM.WW.Secrets.Abstractions; - -/// -/// Internal marker giving the assembly a compilable input while the contract -/// surface is scaffolded. Replace with real contracts as they are implemented. -/// -internal static class AssemblyMarker -{ -} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretContentType.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretContentType.cs new file mode 100644 index 0000000..0002f17 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretContentType.cs @@ -0,0 +1,20 @@ +namespace ZB.MOM.WW.Secrets.Abstractions; + +/// +/// 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. +/// +public enum SecretContentType +{ + /// Arbitrary UTF-8 text (the default). + Text, + + /// A database or service connection string. + ConnectionString, + + /// A JSON document. + Json, + + /// Binary data carried as a Base64-encoded string. + BinaryBase64, +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretManifestEntry.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretManifestEntry.cs new file mode 100644 index 0000000..2504f04 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretManifestEntry.cs @@ -0,0 +1,21 @@ +namespace ZB.MOM.WW.Secrets.Abstractions; + +/// +/// 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. +/// +public record SecretManifestEntry +{ + /// The normalized secret name. + public required SecretName Name { get; init; } + + /// Monotonic revision number used to determine which side is newer. + public required long Revision { get; init; } + + /// When the secret was last updated (UTC). + public required DateTimeOffset UpdatedUtc { get; init; } + + /// Whether the secret is soft-deleted (tombstoned). + public bool IsDeleted { get; init; } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretMetadata.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretMetadata.cs new file mode 100644 index 0000000..54bef56 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretMetadata.cs @@ -0,0 +1,39 @@ +namespace ZB.MOM.WW.Secrets.Abstractions; + +/// +/// The safe, UI-facing projection of a secret. Deliberately carries no +/// 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. +/// +public record SecretMetadata +{ + /// The normalized secret name. + public required SecretName Name { get; init; } + + /// Optional human-readable description. + public string? Description { get; init; } + + /// How the decrypted plaintext should be interpreted. + public required SecretContentType ContentType { get; init; } + + /// Identifier of the key-encryption key (KEK) that wrapped the DEK. + public required string KekId { get; init; } + + /// Monotonic revision number. + public required long Revision { get; init; } + + /// Whether the secret is soft-deleted (tombstoned). + public bool IsDeleted { get; init; } + + /// When the secret was first created (UTC). + public required DateTimeOffset CreatedUtc { get; init; } + + /// When the secret was last updated (UTC). + public required DateTimeOffset UpdatedUtc { get; init; } + + /// Principal that created the secret, if known. + public string? CreatedBy { get; init; } + + /// Principal that last updated the secret, if known. + public string? UpdatedBy { get; init; } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretName.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretName.cs new file mode 100644 index 0000000..3eef4f7 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretName.cs @@ -0,0 +1,63 @@ +namespace ZB.MOM.WW.Secrets.Abstractions; + +/// +/// 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. +/// +public readonly record struct SecretName +{ + private const string AllowedChars = "abcdefghijklmnopqrstuvwxyz0123456789._/-"; + + /// + /// Creates a from the given raw value, normalizing then + /// validating it. + /// + /// The raw name to normalize and validate. + /// + /// The value is null, empty, or whitespace; contains the path-traversal sequence + /// ..; or contains any character outside [a-z0-9._/-] (after trim and + /// lowercasing). + /// + 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)); + } + + 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; + } + + /// The normalized secret name (trimmed, lowercased, allow-list validated). + public string Value { get; } + + /// Returns the normalized . + /// The normalized secret name. + public override string ToString() => Value; + + /// Implicitly converts a to its underlying string value. + /// The secret name to convert. + public static implicit operator string(SecretName name) => name.Value; +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretsExceptions.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretsExceptions.cs new file mode 100644 index 0000000..f1d1869 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretsExceptions.cs @@ -0,0 +1,13 @@ +namespace ZB.MOM.WW.Secrets.Abstractions; + +/// Thrown when the master key (KEK) required to wrap or unwrap a DEK is unavailable. +public sealed class MasterKeyUnavailableException(string message) : InvalidOperationException(message); + +/// Thrown when a secret's ciphertext cannot be decrypted or its authentication tag fails to verify. +public sealed class SecretDecryptionException(string message) : InvalidOperationException(message); + +/// Thrown when a requested secret does not exist (or is tombstoned) in the store. +public sealed class SecretNotFoundException(string message) : InvalidOperationException(message); + +/// Thrown when the secret store cannot be migrated to the supported schema. +public sealed class SecretStoreMigrationException(string message) : InvalidOperationException(message); diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/StoredSecret.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/StoredSecret.cs new file mode 100644 index 0000000..28e8fc8 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/StoredSecret.cs @@ -0,0 +1,61 @@ +namespace ZB.MOM.WW.Secrets.Abstractions; + +/// +/// The encrypted, at-rest representation of a secret — one database row. This type +/// never holds plaintext: the secret material lives only in , +/// 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 . +/// +public record StoredSecret +{ + /// The normalized secret name (primary identity). + public required SecretName Name { get; init; } + + /// Optional human-readable description shown in management surfaces. + public string? Description { get; init; } + + /// How the decrypted plaintext should be interpreted. + public required SecretContentType ContentType { get; init; } + + /// The AES-256-GCM ciphertext of the secret plaintext. + public required byte[] Ciphertext { get; init; } + + /// The AES-256-GCM nonce used to seal . + public required byte[] Nonce { get; init; } + + /// The AES-256-GCM authentication tag for . + public required byte[] Tag { get; init; } + + /// The per-secret data-encryption key (DEK), wrapped by the KEK. + public required byte[] WrappedDek { get; init; } + + /// The AES-256-GCM nonce used to wrap . + public required byte[] WrapNonce { get; init; } + + /// The AES-256-GCM authentication tag for . + public required byte[] WrapTag { get; init; } + + /// Identifier of the key-encryption key (KEK) that wrapped the DEK. + public required string KekId { get; init; } + + /// Monotonic revision number, incremented on each update. + public required long Revision { get; init; } + + /// Whether the secret is soft-deleted (tombstoned). + public bool IsDeleted { get; init; } + + /// When the secret was soft-deleted, if it is deleted; otherwise null. + public DateTimeOffset? DeletedUtc { get; init; } + + /// When the secret was first created (UTC). + public required DateTimeOffset CreatedUtc { get; init; } + + /// When the secret was last updated (UTC). + public required DateTimeOffset UpdatedUtc { get; init; } + + /// Principal that created the secret, if known. + public string? CreatedBy { get; init; } + + /// Principal that last updated the secret, if known. + public string? UpdatedBy { get; init; } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/SecretNameTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/SecretNameTests.cs new file mode 100644 index 0000000..964f783 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/SecretNameTests.cs @@ -0,0 +1,21 @@ +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(() => new SecretName(input)); +} From 16b32257f50b20483e5c8c4ebfe4a00a89218c79 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 16:30:19 -0400 Subject: [PATCH 03/20] feat(secrets): abstractions seam interfaces --- .../IMasterKeyProvider.cs | 24 +++++++ .../ISecretCipher.cs | 35 ++++++++++ .../ISecretReplicator.cs | 17 +++++ .../ISecretResolver.cs | 19 ++++++ .../ISecretStore.cs | 66 +++++++++++++++++++ 5 files changed, 161 insertions(+) create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/IMasterKeyProvider.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretCipher.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretReplicator.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretResolver.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretStore.cs diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/IMasterKeyProvider.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/IMasterKeyProvider.cs new file mode 100644 index 0000000..dd2eb5c --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/IMasterKeyProvider.cs @@ -0,0 +1,24 @@ +namespace ZB.MOM.WW.Secrets.Abstractions; + +/// +/// 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. +/// +public interface IMasterKeyProvider +{ + /// + /// Returns the 32-byte AES-256 master key material. + /// + /// The 32-byte master key. + /// The master key cannot be resolved. + ReadOnlyMemory GetMasterKey(); + + /// + /// 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. + /// + string KekId { get; } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretCipher.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretCipher.cs new file mode 100644 index 0000000..6fe9876 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretCipher.cs @@ -0,0 +1,35 @@ +namespace ZB.MOM.WW.Secrets.Abstractions; + +/// +/// 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 +/// . The secret name is bound as additional authenticated +/// data (AAD) so ciphertext cannot be silently relocated to a different name. +/// +public interface ISecretCipher +{ + /// + /// Envelope-encrypts , binding it to + /// as additional authenticated data. + /// + /// The secret name, bound as AAD. + /// The secret plaintext to seal. + /// How the plaintext should be interpreted by consumers. + /// + /// A carrying the ciphertext, nonce, tag, and wrapped DEK, with + /// 0 and default timestamps — the store stamps the + /// revision and timestamps on persist. + /// + StoredSecret Encrypt(SecretName name, string plaintext, SecretContentType contentType); + + /// + /// Decrypts the value carried by a stored row, verifying its authentication tag and the + /// name it was bound to. + /// + /// The stored row to decrypt. + /// The decrypted plaintext. + /// + /// The authentication tag fails to verify, or the row references an unknown or unavailable KEK. + /// + string Decrypt(StoredSecret secret); +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretReplicator.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretReplicator.cs new file mode 100644 index 0000000..a11dce8 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretReplicator.cs @@ -0,0 +1,17 @@ +namespace ZB.MOM.WW.Secrets.Abstractions; + +/// +/// Publishes an encrypted row to cluster peers. Core registers a no-op implementation; +/// ZB.MOM.WW.Secrets.Akka (deferred) provides the real cluster implementation. +/// +public interface ISecretReplicator +{ + /// + /// Broadcasts the already-encrypted to peer nodes. This is a no-op + /// in single-node deployments. + /// + /// The encrypted row to replicate. + /// A token to cancel the operation. + /// A task that completes when the row has been published. + Task PublishAsync(StoredSecret row, CancellationToken ct); +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretResolver.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretResolver.cs new file mode 100644 index 0000000..2335f37 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretResolver.cs @@ -0,0 +1,19 @@ +namespace ZB.MOM.WW.Secrets.Abstractions; + +/// +/// 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). +/// +public interface ISecretResolver +{ + /// + /// Resolves and decrypts the current plaintext for . + /// + /// The secret to resolve. + /// A token to cancel the operation. + /// + /// The decrypted plaintext, or null if the secret is absent or tombstoned. + /// + Task GetAsync(SecretName name, CancellationToken ct); +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretStore.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretStore.cs new file mode 100644 index 0000000..2662f3a --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretStore.cs @@ -0,0 +1,66 @@ +namespace ZB.MOM.WW.Secrets.Abstractions; + +/// +/// Persists encrypted secret rows and exposes the read, list, and replication operations the +/// core services build on. The store only ever handles the encrypted +/// representation and its safe projection — it never sees plaintext. +/// Deletes are soft (tombstones) so removals can propagate across a cluster. +/// +public interface ISecretStore +{ + /// + /// Returns the row for , including a tombstoned row, or null + /// if no row exists. + /// + /// The secret to fetch. + /// A token to cancel the operation. + /// The stored row, or null if absent. + Task GetAsync(SecretName name, CancellationToken ct); + + /// + /// Inserts the row, or overwrites an existing row in place, bumping its revision and + /// updated timestamp. + /// + /// The encrypted row to persist. + /// A token to cancel the operation. + /// A task that completes when the row is persisted. + Task UpsertAsync(StoredSecret row, CancellationToken ct); + + /// + /// Soft-deletes (tombstones) the row for so the deletion can + /// propagate to peers. + /// + /// The secret to tombstone. + /// The principal to record as updated_by, if known. + /// A token to cancel the operation. + /// true if a row was tombstoned; false if the row was absent. + Task DeleteAsync(SecretName name, string? actor, CancellationToken ct); + + /// + /// Lists the safe metadata projection for all secrets — never ciphertext. + /// + /// + /// When true, tombstoned rows are included; otherwise they are omitted. + /// + /// A token to cancel the operation. + /// The metadata for the matching secrets. + Task> ListAsync(bool includeDeleted, CancellationToken ct); + + /// + /// Returns a compact manifest — name, revision, updated timestamp, and tombstone flag per + /// secret — used for cluster anti-entropy reconciliation without exchanging ciphertext. + /// + /// A token to cancel the operation. + /// The manifest entries for all secrets. + Task> GetManifestAsync(CancellationToken ct); + + /// + /// Applies a row received from a peer using last-writer-wins on + /// (, ). A row that + /// is not newer than the local row is ignored. + /// + /// The replicated encrypted row to apply. + /// A token to cancel the operation. + /// A task that completes when the row has been applied or ignored. + Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct); +} From b784c7117ff73334aa8ad8b0ea84185ee066aa0e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 16:34:46 -0400 Subject: [PATCH 04/20] feat(secrets): AES-256-GCM envelope cipher with AAD binding --- .../SecretsExceptions.cs | 12 +- .../Crypto/AesGcmEnvelopeCipher.cs | 156 ++++++++++++++++++ .../Crypto/AesGcmEnvelopeCipherTests.cs | 109 ++++++++++++ .../Fakes/FakeMasterKeyProvider.cs | 45 +++++ 4 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Crypto/AesGcmEnvelopeCipher.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Crypto/AesGcmEnvelopeCipherTests.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Fakes/FakeMasterKeyProvider.cs diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretsExceptions.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretsExceptions.cs index f1d1869..b09302f 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretsExceptions.cs +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretsExceptions.cs @@ -4,7 +4,17 @@ namespace ZB.MOM.WW.Secrets.Abstractions; public sealed class MasterKeyUnavailableException(string message) : InvalidOperationException(message); /// Thrown when a secret's ciphertext cannot be decrypted or its authentication tag fails to verify. -public sealed class SecretDecryptionException(string message) : InvalidOperationException(message); +public sealed class SecretDecryptionException : InvalidOperationException +{ + /// Creates the exception with a message describing the decryption failure. + /// The failure description. + public SecretDecryptionException(string message) : base(message) { } + + /// Creates the exception, preserving the underlying cryptographic fault. + /// The failure description. + /// The original cryptographic exception that caused the failure. + public SecretDecryptionException(string message, Exception innerException) : base(message, innerException) { } +} /// Thrown when a requested secret does not exist (or is tombstoned) in the store. public sealed class SecretNotFoundException(string message) : InvalidOperationException(message); diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Crypto/AesGcmEnvelopeCipher.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Crypto/AesGcmEnvelopeCipher.cs new file mode 100644 index 0000000..c5d580c --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Crypto/AesGcmEnvelopeCipher.cs @@ -0,0 +1,156 @@ +using System.Security.Cryptography; +using System.Text; +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.Secrets.Crypto; + +/// +/// The default : AES-256-GCM envelope encryption. +/// +/// +/// +/// 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 as additional authenticated data (AAD) so ciphertext +/// cannot be silently relocated to a different name. The DEK is then wrapped with a second +/// AES-256-GCM operation under the master key (KEK) from an , +/// binding the provider's as AAD so a row can only be +/// unwrapped by the KEK that is claimed to have sealed it. +/// +/// +/// 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 — is surfaced as a +/// ; the cipher never returns a wrong plaintext and never +/// leaks a raw cryptographic exception. The transient DEK buffer is always zeroed with +/// . +/// +/// +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; + + /// + /// Creates a cipher that wraps and unwraps DEKs with the master key supplied by + /// . + /// + /// The source of the AES-256 master key (KEK) and its id. + public AesGcmEnvelopeCipher(IMasterKeyProvider masterKeyProvider) + { + _masterKeyProvider = masterKeyProvider ?? throw new ArgumentNullException(nameof(masterKeyProvider)); + } + + /// + public StoredSecret Encrypt(SecretName name, string plaintext, SecretContentType contentType) + { + ArgumentNullException.ThrowIfNull(plaintext); + + string kekId = _masterKeyProvider.KekId; + ReadOnlySpan 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); + } + } + + /// + 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 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); + } + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Crypto/AesGcmEnvelopeCipherTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Crypto/AesGcmEnvelopeCipherTests.cs new file mode 100644 index 0000000..097af48 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Crypto/AesGcmEnvelopeCipherTests.cs @@ -0,0 +1,109 @@ +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 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(() => 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(() => 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(() => 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(() => 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; + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Fakes/FakeMasterKeyProvider.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Fakes/FakeMasterKeyProvider.cs new file mode 100644 index 0000000..ac2b737 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Fakes/FakeMasterKeyProvider.cs @@ -0,0 +1,45 @@ +using System.Security.Cryptography; +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.Secrets.Tests.Fakes; + +/// +/// A hand-written 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 . Two instances with the same string +/// still hold different key bytes, which lets a test prove that a matching KEK id but wrong key +/// material fails closed. +/// +public sealed class FakeMasterKeyProvider : IMasterKeyProvider +{ + private readonly byte[] _key; + + /// + /// Creates a provider with a freshly generated random 32-byte master key and the given + /// . + /// + /// The stable, non-secret key identifier this provider reports. + public FakeMasterKeyProvider(string kekId = "k1") + { + KekId = kekId; + _key = RandomNumberGenerator.GetBytes(32); + } + + /// + /// 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). + /// + /// The stable, non-secret key identifier this provider reports. + /// The exact 32-byte master key material to use. + public FakeMasterKeyProvider(string kekId, byte[] key) + { + KekId = kekId; + _key = (byte[])key.Clone(); + } + + /// + public string KekId { get; } + + /// + public ReadOnlyMemory GetMasterKey() => _key; +} From 01911508b9bd523030efc240200488c1e8f79cb8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 16:37:21 -0400 Subject: [PATCH 05/20] fix(secrets): harden SecretName (reject rooted paths, fail-fast on default) + keep SecretDecryptionException sealed --- .../SecretName.cs | 22 +++++++++++++++---- .../SecretNameTests.cs | 18 +++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretName.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretName.cs index 3eef4f7..7a8a0b4 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretName.cs +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretName.cs @@ -16,8 +16,9 @@ public readonly record struct SecretName /// The raw name to normalize and validate. /// /// The value is null, empty, or whitespace; contains the path-traversal sequence - /// ..; or contains any character outside [a-z0-9._/-] (after trim and - /// lowercasing). + /// ..; is rooted (starts or ends with /) or contains an empty path + /// segment (//); or contains any character outside [a-z0-9._/-] (after + /// trim and lowercasing). /// public SecretName(string value) { @@ -37,6 +38,14 @@ public readonly record struct SecretName 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)) @@ -47,17 +56,22 @@ public readonly record struct SecretName } } - Value = normalized; + _value = normalized; } + private readonly string? _value; + /// The normalized secret name (trimmed, lowercased, allow-list validated). - public string Value { get; } + /// The instance is the uninitialized default struct value. + public string Value => _value ?? throw new InvalidOperationException("SecretName is uninitialized (default struct value)."); /// Returns the normalized . /// The normalized secret name. + /// The instance is the uninitialized default struct value. public override string ToString() => Value; /// Implicitly converts a to its underlying string value. /// The secret name to convert. + /// The instance is the uninitialized default struct value. public static implicit operator string(SecretName name) => name.Value; } diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/SecretNameTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/SecretNameTests.cs index 964f783..ff9c8c0 100644 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/SecretNameTests.cs +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/SecretNameTests.cs @@ -18,4 +18,22 @@ public class SecretNameTests [InlineData("../escape")] public void RejectsInvalid(string input) => Assert.Throws(() => 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(() => new SecretName(input)); + + [Fact] + public void DefaultInstance_ThrowsOnValue() + => Assert.Throws(() => _ = default(SecretName).Value); + + [Fact] + public void ValidNestedName_RoundTrips() + => Assert.Equal( + "sql/historiangw/historian-password", + new SecretName("sql/historiangw/historian-password").Value); } From 1c13b4af997fe28cdb42b617c8108833982d6c83 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 16:43:10 -0400 Subject: [PATCH 06/20] feat(secrets): pluggable master-key providers (env/file/dpapi) + factory --- ZB.MOM.WW.Secrets/Directory.Packages.props | 3 + .../MasterKey/DpapiMasterKeyProvider.cs | 57 +++++ .../MasterKey/EnvironmentMasterKeyProvider.cs | 39 +++ .../MasterKey/FileMasterKeyProvider.cs | 58 +++++ .../MasterKey/MasterKeyOptions.cs | 50 ++++ .../MasterKey/MasterKeyProviderBase.cs | 79 ++++++ .../MasterKey/MasterKeyProviderFactory.cs | 36 +++ .../ZB.MOM.WW.Secrets.csproj | 1 + .../Crypto/AesGcmEnvelopeCipherTests.cs | 16 ++ .../MasterKey/MasterKeyProviderTests.cs | 229 ++++++++++++++++++ 10 files changed, 568 insertions(+) create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/DpapiMasterKeyProvider.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/EnvironmentMasterKeyProvider.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/FileMasterKeyProvider.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyOptions.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderBase.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderFactory.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/MasterKey/MasterKeyProviderTests.cs diff --git a/ZB.MOM.WW.Secrets/Directory.Packages.props b/ZB.MOM.WW.Secrets/Directory.Packages.props index 0fb7b4c..84dca02 100644 --- a/ZB.MOM.WW.Secrets/Directory.Packages.props +++ b/ZB.MOM.WW.Secrets/Directory.Packages.props @@ -11,6 +11,9 @@ + + + diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/DpapiMasterKeyProvider.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/DpapiMasterKeyProvider.cs new file mode 100644 index 0000000..a9424ac --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/DpapiMasterKeyProvider.cs @@ -0,0 +1,57 @@ +using System.Runtime.Versioning; +using System.Security.Cryptography; +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.Secrets.MasterKey; + +/// +/// Resolves the master key (KEK) by unprotecting a DPAPI-sealed blob file at +/// under the current-user scope. Windows-only. +/// +/// +/// The file must contain the ciphertext produced by +/// for the 32-byte key. +/// The blob is read and unprotected fresh on every call. On a non-Windows OS +/// throws . +/// +[SupportedOSPlatform("windows")] +public sealed class DpapiMasterKeyProvider : MasterKeyProviderBase +{ + /// Creates the provider bound to . + /// The master-key configuration (requires ). + public DpapiMasterKeyProvider(MasterKeyOptions options) : base(options) + { + } + + /// + 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}"); + } + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/EnvironmentMasterKeyProvider.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/EnvironmentMasterKeyProvider.cs new file mode 100644 index 0000000..e2b15ae --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/EnvironmentMasterKeyProvider.cs @@ -0,0 +1,39 @@ +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.Secrets.MasterKey; + +/// +/// Resolves the master key (KEK) from a base64-encoded environment variable named by +/// . The variable is read fresh on every call so a rotated +/// value takes effect immediately. +/// +public sealed class EnvironmentMasterKeyProvider : MasterKeyProviderBase +{ + /// Creates the provider bound to . + /// The master-key configuration (uses ). + public EnvironmentMasterKeyProvider(MasterKeyOptions options) : base(options) + { + } + + /// + 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}"); + } + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/FileMasterKeyProvider.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/FileMasterKeyProvider.cs new file mode 100644 index 0000000..a317c81 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/FileMasterKeyProvider.cs @@ -0,0 +1,58 @@ +using System.Text; +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.Secrets.MasterKey; + +/// +/// Resolves the master key (KEK) from the file at . 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. +/// +/// +/// 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. +/// +public sealed class FileMasterKeyProvider : MasterKeyProviderBase +{ + /// Creates the provider bound to . + /// The master-key configuration (requires ). + public FileMasterKeyProvider(MasterKeyOptions options) : base(options) + { + } + + /// + 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}"); + } + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyOptions.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyOptions.cs new file mode 100644 index 0000000..04b3282 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyOptions.cs @@ -0,0 +1,50 @@ +namespace ZB.MOM.WW.Secrets.MasterKey; + +/// +/// Identifies where a resolves its 32-byte master key (KEK) from. +/// +public enum MasterKeySource +{ + /// Read a base64-encoded key from an environment variable. + Environment, + + /// Read the key from a file (raw 32 bytes, or base64/hex text). + File, + + /// Unprotect a DPAPI-sealed blob file (Windows / ). + Dpapi, +} + +/// +/// Configures which master-key (KEK) source a selects and how +/// it resolves the key material. +/// +/// +/// 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). +/// +public sealed class MasterKeyOptions +{ + /// The source the master key is resolved from. Defaults to . + public MasterKeySource Source { get; set; } = MasterKeySource.Environment; + + /// + /// The environment variable holding the base64-encoded 32-byte key, used when + /// is . Defaults to + /// ZB_SECRETS_MASTER_KEY. + /// + public string EnvVarName { get; set; } = "ZB_SECRETS_MASTER_KEY"; + + /// + /// The path to the key file, used when is + /// or . + /// + public string? FilePath { get; set; } + + /// + /// An optional explicit key-encryption-key identifier. When non-empty it is used verbatim as the + /// provider's ; otherwise a deterministic, + /// non-secret id is derived from the key material. + /// + public string? KekId { get; set; } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderBase.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderBase.cs new file mode 100644 index 0000000..0f4e1d6 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderBase.cs @@ -0,0 +1,79 @@ +using System.Security.Cryptography; +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.Secrets.MasterKey; + +/// +/// Shared base for the concrete implementations. Centralizes the +/// 32-byte key-length validation and the non-secret derivation so every source +/// (environment, file, DPAPI) fails closed identically and reports a stable identifier. +/// +/// +/// The key is re-resolved on every call so that a rotated environment or +/// file value takes effect without recreating the provider. 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. +/// +public abstract class MasterKeyProviderBase : IMasterKeyProvider +{ + /// The required master-key length in bytes (AES-256). + protected const int KeySizeBytes = 32; + + private readonly MasterKeyOptions _options; + + /// Creates the base provider bound to the given . + /// The master-key configuration. + protected MasterKeyProviderBase(MasterKeyOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// The master-key configuration this provider was created with. + protected MasterKeyOptions Options => _options; + + /// + public ReadOnlyMemory GetMasterKey() => ResolveValidatedKey(); + + /// + public string KekId + { + get + { + string? explicitId = _options.KekId; + if (!string.IsNullOrEmpty(explicitId)) + { + return explicitId; + } + + return DeriveKekId(ResolveValidatedKey()); + } + } + + /// + /// Resolves the raw key material from the concrete source. Implementations throw + /// when the source is missing or malformed; length + /// validation is applied by the base class. + /// + /// The resolved key bytes (any length; validated by the caller). + 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(); + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderFactory.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderFactory.cs new file mode 100644 index 0000000..1a2c199 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderFactory.cs @@ -0,0 +1,36 @@ +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.Secrets.MasterKey; + +/// +/// Selects and constructs the concrete matching a +/// . +/// +public static class MasterKeyProviderFactory +{ + /// + /// Creates the master-key provider for the configured . + /// + /// The master-key configuration. + /// The matching provider. + /// is . + /// The configured source is not a known value. + 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."), + }; + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj index 2776ae5..450ec10 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj @@ -6,6 +6,7 @@ + diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Crypto/AesGcmEnvelopeCipherTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Crypto/AesGcmEnvelopeCipherTests.cs index 097af48..b7f8eb1 100644 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Crypto/AesGcmEnvelopeCipherTests.cs +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Crypto/AesGcmEnvelopeCipherTests.cs @@ -25,6 +25,22 @@ public class AesGcmEnvelopeCipherTests 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() { diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/MasterKey/MasterKeyProviderTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/MasterKey/MasterKeyProviderTests.cs new file mode 100644 index 0000000..4450466 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/MasterKey/MasterKeyProviderTests.cs @@ -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(() => 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(() => 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(() => 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(() => 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(provider); + } + + [Fact] + public void Factory_File_ReturnsFileProvider() + { + IMasterKeyProvider provider = MasterKeyProviderFactory.Create( + new MasterKeyOptions { Source = MasterKeySource.File, FilePath = "unused" }); + + Assert.IsType(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"); +} From e99a8731e93056456b00b41dba00da8f719c3552 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 16:46:15 -0400 Subject: [PATCH 07/20] feat(secrets): sqlite connection factory + schema v1 + migrator --- .../Sqlite/SecretsSqliteConnectionFactory.cs | 86 +++++++++++ .../Sqlite/SqliteSecretsSchema.cs | 59 ++++++++ .../Sqlite/SqliteSecretsStoreMigrator.cs | 138 ++++++++++++++++++ .../Sqlite/SqliteSecretsStoreMigratorTests.cs | 127 ++++++++++++++++ 4 files changed, 410 insertions(+) create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SecretsSqliteConnectionFactory.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsSchema.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsStoreMigrator.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Sqlite/SqliteSecretsStoreMigratorTests.cs diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SecretsSqliteConnectionFactory.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SecretsSqliteConnectionFactory.cs new file mode 100644 index 0000000..25998ee --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SecretsSqliteConnectionFactory.cs @@ -0,0 +1,86 @@ +using Microsoft.Data.Sqlite; + +namespace ZB.MOM.WW.Secrets.Sqlite; + +/// +/// Factory for creating and opening SQLite connections to the secret store. +/// +public sealed class SecretsSqliteConnectionFactory +{ + /// + /// Busy timeout applied to every connection. SQLite retries a busy database for + /// this long before surfacing SQLITE_BUSY, so concurrent writers degrade + /// gracefully under load instead of failing the request path. + /// + private static readonly TimeSpan BusyTimeout = TimeSpan.FromSeconds(5); + + private readonly string _sqlitePath; + + /// Creates a factory targeting the database at . + /// Filesystem path of the SQLite database file. + public SecretsSqliteConnectionFactory(string sqlitePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sqlitePath); + _sqlitePath = sqlitePath; + } + + /// + /// Creates an unopened SQLite connection (Mode=ReadWriteCreate). Prefer + /// , which also applies WAL journaling and the + /// busy timeout. + /// + 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()); + } + + /// + /// 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 SQLITE_BUSY as a hard failure. + /// + /// Cancellation token for the operation. + /// An opened and configured SQLite connection. + public async Task 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); + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsSchema.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsSchema.cs new file mode 100644 index 0000000..4443c57 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsSchema.cs @@ -0,0 +1,59 @@ +namespace ZB.MOM.WW.Secrets.Sqlite; + +/// +/// Schema constants and table DDL for the secret SQLite store. +/// +public static class SqliteSecretsSchema +{ + /// + /// The schema version this build creates and supports. This is the first version, so all DDL is a + /// single-shot CREATE ... IF NOT EXISTS. The migrator refuses an on-disk version + /// newer than this. Future additive columns (nullable ALTER TABLEs, gated by a + /// PRAGMA table_info existence probe) would bump this and register a forward migration. + /// + public const int CurrentVersion = 1; + + /// Name of the single-row table tracking the applied schema version. + public const string SchemaVersionTable = "schema_version"; + + /// Name of the table storing envelope-encrypted secret records. + public const string SecretTable = "secret"; + + /// DDL creating the single-row schema-version table. + 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 + ); + """; + + /// DDL creating the envelope-encrypted secret record table. + 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 + ); + """; + + /// DDL creating supporting indexes (idempotent). + public const string CreateIndexes = """ + CREATE INDEX IF NOT EXISTS ix_secret_updated_utc + ON secret (updated_utc); + """; +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsStoreMigrator.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsStoreMigrator.cs new file mode 100644 index 0000000..1198f3e --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsStoreMigrator.cs @@ -0,0 +1,138 @@ +using System.Globalization; +using Microsoft.Data.Sqlite; +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.Secrets.Sqlite; + +/// +/// 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. +/// +public sealed class SqliteSecretsStoreMigrator(SecretsSqliteConnectionFactory connectionFactory) +{ + /// Applies the schema migration to the secret store. + /// Cancellation token. + /// + /// The on-disk schema version is newer than . + /// + 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 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); + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Sqlite/SqliteSecretsStoreMigratorTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Sqlite/SqliteSecretsStoreMigratorTests.cs new file mode 100644 index 0000000..c11672f --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Sqlite/SqliteSecretsStoreMigratorTests.cs @@ -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 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( + () => migrator.MigrateAsync(CancellationToken.None)); + } + + private static async Task 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> 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(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. + } + } + } +} From 10455ec53b92d41a49b9731de24454412852648f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 16:50:59 -0400 Subject: [PATCH 08/20] feat(secrets): sqlite secret store (overwrite-in-place, tombstone, manifest, LWW) --- .../MasterKey/MasterKeyOptions.cs | 2 +- .../Sqlite/SqliteSecretStore.cs | 302 ++++++++++++++++++ .../Sqlite/SqliteSecretStoreTests.cs | 227 +++++++++++++ 3 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretStore.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Sqlite/SqliteSecretStoreTests.cs diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyOptions.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyOptions.cs index 04b3282..f251d27 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyOptions.cs +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyOptions.cs @@ -8,7 +8,7 @@ public enum MasterKeySource /// Read a base64-encoded key from an environment variable. Environment, - /// Read the key from a file (raw 32 bytes, or base64/hex text). + /// Read the key from a file (raw 32 bytes, or base64 text). File, /// Unprotect a DPAPI-sealed blob file (Windows / ). diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretStore.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretStore.cs new file mode 100644 index 0000000..4b5afd5 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretStore.cs @@ -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; + +/// +/// SQLite-backed . Persists the envelope-encrypted +/// rows and their safe projection, +/// using parameterized commands throughout. Local writes go through +/// (which bumps the revision); replicated rows go through +/// (last-writer-wins, applied verbatim). +/// +/// Timestamps are stored as round-trippable ISO-8601 ("O") TEXT; +/// content_type as the enum name; is_deleted as a 0/1 integer. +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"; + + /// + public async Task 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); + } + + /// + 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); + } + + /// + public async Task 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; + } + + /// + public async Task> 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(); + 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(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; + } + + /// + public async Task> 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(); + 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; + } + + /// + 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(reader.GetString(2)), + Ciphertext = reader.GetFieldValue(3), + Nonce = reader.GetFieldValue(4), + Tag = reader.GetFieldValue(5), + WrappedDek = reader.GetFieldValue(6), + WrapNonce = reader.GetFieldValue(7), + WrapTag = reader.GetFieldValue(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); +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Sqlite/SqliteSecretStoreTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Sqlite/SqliteSecretStoreTests.cs new file mode 100644 index 0000000..16bd68a --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Sqlite/SqliteSecretStoreTests.cs @@ -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 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 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 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. + } + } + } +} From 79ebd14baa782ac13ee726df59de807b803d683c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 16:55:24 -0400 Subject: [PATCH 09/20] feat(secrets): default resolver with TTL cache + audit (never logs plaintext) --- .../ISecretActorAccessor.cs | 17 ++ .../DefaultSecretResolver.cs | 140 ++++++++++++++++ .../DefaultSecretResolverTests.cs | 158 ++++++++++++++++++ .../Fakes/CapturingAuditWriter.cs | 24 +++ .../Fakes/CountingSecretStore.cs | 51 ++++++ .../Fakes/FixedActorAccessor.cs | 16 ++ .../Fakes/MutableTimeProvider.cs | 29 ++++ 7 files changed, 435 insertions(+) create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretActorAccessor.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DefaultSecretResolver.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DefaultSecretResolverTests.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Fakes/CapturingAuditWriter.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Fakes/CountingSecretStore.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Fakes/FixedActorAccessor.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Fakes/MutableTimeProvider.cs diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretActorAccessor.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretActorAccessor.cs new file mode 100644 index 0000000..9f8f1dc --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretActorAccessor.cs @@ -0,0 +1,17 @@ +namespace ZB.MOM.WW.Secrets.Abstractions; + +/// +/// 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. A null +/// (or no accessor at all) is treated as the "system" actor by the resolver, so keyless / +/// background resolutions are still attributed rather than left blank. +/// +public interface ISecretActorAccessor +{ + /// + /// The current principal to record as the audit Actor, or null when no + /// principal is available (treated as "system"). + /// + string? CurrentActor { get; } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DefaultSecretResolver.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DefaultSecretResolver.cs new file mode 100644 index 0000000..5325258 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DefaultSecretResolver.cs @@ -0,0 +1,140 @@ +using System.Collections.Concurrent; +using ZB.MOM.WW.Audit; +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.Secrets; + +/// +/// The default : 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, or +/// tombstone — emits an recording which secret was accessed and the +/// outcome. The plaintext value is never placed in any audit field. +/// +/// +/// +/// Security note — the cache holds decrypted secret material in process memory. 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. lets the write path evict +/// an entry immediately after a rotate or delete. The cache is keyed by the normalized +/// and is safe for concurrent use. +/// +/// +/// A missing or tombstoned secret is not an exception: returns +/// null and audits a with a not-found action, so a +/// consumer can distinguish "absent" from "present" without a throw on the hot path. +/// +/// +public sealed class DefaultSecretResolver : ISecretResolver +{ + 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 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 _cache = new(StringComparer.Ordinal); + + /// + /// Creates a resolver over the given store, cipher, and audit writer. + /// + /// The encrypted-row store to read from. + /// The cipher used to decrypt a loaded row. + /// The audit sink; one event is written per resolve (never the value). + /// + /// 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). + /// + /// + /// Optional accessor for the current principal to attribute the audit to; a null accessor + /// (or a null ) is recorded as + /// "system". + /// + /// The clock (defaults to ). + 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; + } + + /// + public async Task 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. + string plaintext = _cipher.Decrypt(row); + _cache[key] = new CacheEntry(plaintext, now + _cacheTtl, row.Revision); + + await AuditSuccessAsync(name, source: "store", ct).ConfigureAwait(false); + return plaintext; + } + + /// + /// Evicts the cached plaintext for , if any. The admin / write path + /// calls this immediately after a rotate or delete so a stale value is not served for the + /// remainder of its TTL. Not part of ; cheap and idempotent. + /// + /// The secret whose cache entry to remove. + 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 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); + } + + /// A cached decrypted secret and its expiry (revision kept for future invalidation). + private readonly record struct CacheEntry(string Plaintext, DateTimeOffset ExpiresUtc, long Revision); +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DefaultSecretResolverTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DefaultSecretResolverTests.cs new file mode 100644 index 0000000..e7202c5 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DefaultSecretResolverTests.cs @@ -0,0 +1,158 @@ +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); + + /// + /// Builds a real encrypted row for 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. + /// + 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 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 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); + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Fakes/CapturingAuditWriter.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Fakes/CapturingAuditWriter.cs new file mode 100644 index 0000000..0da2d7d --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Fakes/CapturingAuditWriter.cs @@ -0,0 +1,24 @@ +using System.Collections.Concurrent; +using ZB.MOM.WW.Audit; + +namespace ZB.MOM.WW.Secrets.Tests.Fakes; + +/// +/// An test double that collects every written, +/// so a test can assert on the audit trail (outcome, target, and — critically — that no plaintext +/// ever appears in any field). +/// +public sealed class CapturingAuditWriter : IAuditWriter +{ + private readonly ConcurrentQueue _events = new(); + + /// The events captured so far, in write order. + public IReadOnlyList Events => _events.ToArray(); + + /// + public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) + { + _events.Enqueue(evt); + return Task.CompletedTask; + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Fakes/CountingSecretStore.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Fakes/CountingSecretStore.cs new file mode 100644 index 0000000..1ffd006 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Fakes/CountingSecretStore.cs @@ -0,0 +1,51 @@ +using System.Collections.Concurrent; +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.Secrets.Tests.Fakes; + +/// +/// An in-memory test double that counts calls so +/// a test can prove a resolver cache hit (or miss). Only the read path exercised by +/// DefaultSecretResolver is meaningfully implemented; the mutation / replication members +/// throw so an accidental dependency on them fails loudly rather than silently no-ops. +/// +public sealed class CountingSecretStore : ISecretStore +{ + private readonly ConcurrentDictionary _rows = new(StringComparer.Ordinal); + private int _getCount; + + /// The number of times has been invoked. + public int GetCount => Volatile.Read(ref _getCount); + + /// Seeds (or overwrites) a row keyed by its normalized name. + /// The encrypted row to seed. + public void Seed(StoredSecret row) => _rows[row.Name.Value] = row; + + /// + public Task GetAsync(SecretName name, CancellationToken ct) + { + Interlocked.Increment(ref _getCount); + _rows.TryGetValue(name.Value, out StoredSecret? row); + return Task.FromResult(row); + } + + /// + public Task UpsertAsync(StoredSecret row, CancellationToken ct) => + throw new NotSupportedException(); + + /// + public Task DeleteAsync(SecretName name, string? actor, CancellationToken ct) => + throw new NotSupportedException(); + + /// + public Task> ListAsync(bool includeDeleted, CancellationToken ct) => + throw new NotSupportedException(); + + /// + public Task> GetManifestAsync(CancellationToken ct) => + throw new NotSupportedException(); + + /// + public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) => + throw new NotSupportedException(); +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Fakes/FixedActorAccessor.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Fakes/FixedActorAccessor.cs new file mode 100644 index 0000000..15b1f21 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Fakes/FixedActorAccessor.cs @@ -0,0 +1,16 @@ +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.Secrets.Tests.Fakes; + +/// +/// An test double that reports a fixed (or null) actor. +/// +public sealed class FixedActorAccessor : ISecretActorAccessor +{ + /// Creates an accessor that always reports . + /// The actor to report, or null. + public FixedActorAccessor(string? actor) => CurrentActor = actor; + + /// + public string? CurrentActor { get; } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Fakes/MutableTimeProvider.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Fakes/MutableTimeProvider.cs new file mode 100644 index 0000000..592e60d --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Fakes/MutableTimeProvider.cs @@ -0,0 +1,29 @@ +namespace ZB.MOM.WW.Secrets.Tests.Fakes; + +/// +/// A test double with a settable , so a test can +/// advance the clock past a cache TTL deterministically without sleeping. +/// +public sealed class MutableTimeProvider : TimeProvider +{ + private DateTimeOffset _utcNow; + + /// Creates a provider starting at the given instant (defaults to the Unix epoch). + /// The initial value. + public MutableTimeProvider(DateTimeOffset? start = null) => + _utcNow = start ?? DateTimeOffset.UnixEpoch; + + /// Gets or sets the current UTC instant this provider reports. + public DateTimeOffset UtcNow + { + get => _utcNow; + set => _utcNow = value; + } + + /// Moves the clock forward by . + /// The amount to advance. + public void Advance(TimeSpan delta) => _utcNow += delta; + + /// + public override DateTimeOffset GetUtcNow() => _utcNow; +} From 0bb553212aaced23438eecd0e3e748de18424751 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 16:58:34 -0400 Subject: [PATCH 10/20] feat(secrets): ${secret:} config expander (fail-closed) --- .../Configuration/SecretReferenceExpander.cs | 109 ++++++++++++++++++ .../ZB.MOM.WW.Secrets.csproj | 1 + .../SecretReferenceExpanderTests.cs | 106 +++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Configuration/SecretReferenceExpander.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretReferenceExpanderTests.cs diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Configuration/SecretReferenceExpander.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Configuration/SecretReferenceExpander.cs new file mode 100644 index 0000000..2edf0ac --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Configuration/SecretReferenceExpander.cs @@ -0,0 +1,109 @@ +using System.Text.RegularExpressions; +using Microsoft.Extensions.Configuration; +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.Secrets.Configuration; + +/// +/// Expands ${secret:name} reference tokens in configuration values by resolving each +/// referenced secret through an . Expansion is fail-closed: +/// a token that names an absent secret throws 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. +/// +public sealed class SecretReferenceExpander +{ + private const string TokenMarker = "${secret:"; + + private static readonly Regex TokenPattern = new(@"\$\{secret:([^}]+)\}", RegexOptions.Compiled); + + private readonly ISecretResolver _resolver; + + /// Creates an expander that resolves references via . + /// The resolver used to fetch each referenced secret's plaintext. + public SecretReferenceExpander(ISecretResolver resolver) => + _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); + + /// + /// Replaces every ${secret:name} token in 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. + /// + /// The raw configuration value, possibly containing reference tokens. + /// A token to cancel the operation. + /// + /// The expanded value, or unchanged when it is null or contains + /// no tokens. + /// + /// A referenced secret does not exist (fail-closed). + public async Task 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(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()]); + } + + /// + /// Walks every entry in and rewrites, in place, any value that + /// contains a ${secret:...} token to its expanded plaintext. Writes go through the + /// indexer so subsequent reads — and any options binding done + /// afterward — observe the expanded values. + /// + /// + /// This MUST run after configuration is loaded and before options validation + /// or ValidateOnStart executes; otherwise validators may see the unexpanded placeholder. + /// Fail-closed: a missing referenced secret propagates out + /// of startup. + /// + /// The loaded configuration root to mutate. + /// A token to cancel the operation. + /// A referenced secret does not exist (fail-closed). + 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>(); + foreach (KeyValuePair entry in config.AsEnumerable()) + { + if (entry.Value is { } value && value.Contains(TokenMarker, StringComparison.Ordinal)) + { + pending.Add(new KeyValuePair(entry.Key, value)); + } + } + + foreach (KeyValuePair entry in pending) + { + config[entry.Key] = await ExpandAsync(entry.Value, ct).ConfigureAwait(false); + } + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj index 450ec10..759ce67 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj @@ -11,6 +11,7 @@ + diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretReferenceExpanderTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretReferenceExpanderTests.cs new file mode 100644 index 0000000..dd868f8 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretReferenceExpanderTests.cs @@ -0,0 +1,106 @@ +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 _values; + + public FakeSecretResolver(Dictionary values) => _values = values; + + public Task 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(); + 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( + () => 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 + { + ["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 + { + ["ConnectionStrings:Db"] = "Server=.;Password=${secret:sql/absent}", + }) + .Build(); + + await Assert.ThrowsAsync( + () => expander.ExpandConfigurationAsync(config, CancellationToken.None)); + } +} From 8c4175d8ceeb821a163d05e82f8e0027fd561cc0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 17:01:10 -0400 Subject: [PATCH 11/20] fix(secrets): audit decryption failures in resolver (fail-loud, no plaintext) --- .../DefaultSecretResolver.cs | 35 ++++++++++++++++--- .../DefaultSecretResolverTests.cs | 31 ++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DefaultSecretResolver.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DefaultSecretResolver.cs index 5325258..6875fc7 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DefaultSecretResolver.cs +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DefaultSecretResolver.cs @@ -6,9 +6,10 @@ namespace ZB.MOM.WW.Secrets; /// /// The default : 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, or -/// tombstone — emits an recording which secret was accessed and the -/// outcome. The plaintext value is never placed in any audit field. +/// on demand, and serves it from a short-lived in-memory cache. Every resolution — hit, miss, +/// tombstone, or decryption failure — emits an recording which +/// secret was accessed and the outcome. The plaintext value is never placed in any audit +/// field. /// /// /// @@ -23,6 +24,13 @@ namespace ZB.MOM.WW.Secrets; /// null and audits a with a not-found action, so a /// consumer can distinguish "absent" from "present" without a throw on the hot path. /// +/// +/// A decryption or integrity failure (tampered ciphertext or the wrong KEK) is different: it +/// is fail-loud. audits a with a +/// decryption-failed action and then rethrows the — it +/// is never masked as a benign miss or a null. The audit records only the secret name; the +/// ciphertext and any value are never included. +/// /// public sealed class DefaultSecretResolver : ISecretResolver { @@ -30,6 +38,7 @@ public sealed class DefaultSecretResolver : ISecretResolver 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; @@ -94,8 +103,20 @@ public sealed class DefaultSecretResolver : ISecretResolver return null; } - // Decrypt on demand and cache the plaintext for the short TTL. - string plaintext = _cipher.Decrypt(row); + // 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); @@ -116,6 +137,10 @@ public sealed class DefaultSecretResolver : ISecretResolver 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) { diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DefaultSecretResolverTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DefaultSecretResolverTests.cs index e7202c5..631b2fe 100644 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DefaultSecretResolverTests.cs +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DefaultSecretResolverTests.cs @@ -118,6 +118,37 @@ public class DefaultSecretResolverTests 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(() => 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() { From 7f1db1b214bb748061767024b7094a2ba88cbbfb Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 17:05:30 -0400 Subject: [PATCH 12/20] feat(secrets): SecretsOptions + AddZbSecrets DI + migration hosted service --- .../NoOpSecretReplicator.cs | 14 +++ .../SecretsMigrationHostedService.cs | 27 ++++ .../SecretsServiceCollectionExtensions.cs | 87 +++++++++++++ .../src/ZB.MOM.WW.Secrets/SecretsOptions.cs | 37 ++++++ .../DependencyInjection/AddZbSecretsTests.cs | 116 ++++++++++++++++++ .../ZB.MOM.WW.Secrets.Tests.csproj | 2 + 6 files changed, 283 insertions(+) create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/NoOpSecretReplicator.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsMigrationHostedService.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/SecretsOptions.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DependencyInjection/AddZbSecretsTests.cs diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/NoOpSecretReplicator.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/NoOpSecretReplicator.cs new file mode 100644 index 0000000..04cfe87 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/NoOpSecretReplicator.cs @@ -0,0 +1,14 @@ +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.Secrets.DependencyInjection; + +/// +/// The single-node default : publishing a row is a no-op because +/// there are no peers to broadcast to. The deferred ZB.MOM.WW.Secrets.Akka package replaces +/// this registration with a real cluster replicator for clustered deployments. +/// +public sealed class NoOpSecretReplicator : ISecretReplicator +{ + /// + public Task PublishAsync(StoredSecret row, CancellationToken ct) => Task.CompletedTask; +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsMigrationHostedService.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsMigrationHostedService.cs new file mode 100644 index 0000000..bf58beb --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsMigrationHostedService.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.Secrets.Sqlite; + +namespace ZB.MOM.WW.Secrets.DependencyInjection; + +/// +/// Runs the secrets SQLite schema migration at application startup when +/// is . The migration is +/// idempotent, so repeated restarts are safe. +/// +internal sealed class SecretsMigrationHostedService( + SqliteSecretsStoreMigrator migrator, + IOptions options) : IHostedService +{ + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + if (options.Value.RunMigrationsOnStartup) + { + await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false); + } + } + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs new file mode 100644 index 0000000..4d73a5b --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs @@ -0,0 +1,87 @@ +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; + +/// +/// 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. +/// +public static class SecretsServiceCollectionExtensions +{ + /// + /// Registers the secrets subsystem: binds from the configuration + /// section at 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). + /// + /// The service collection to add to. + /// The application configuration. + /// Path of the configuration section holding the secrets options. + /// The same instance, for chaining. + public static IServiceCollection AddZbSecrets( + this IServiceCollection services, + IConfiguration config, + string sectionPath) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(config); + ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath); + + services.Configure(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>().Value.SqlitePath)); + + services.TryAddSingleton(sp => + MasterKeyProviderFactory.Create( + sp.GetRequiredService>().Value.MasterKey)); + + services.TryAddSingleton(sp => + new AesGcmEnvelopeCipher(sp.GetRequiredService())); + + services.TryAddSingleton(sp => + new SqliteSecretStore(sp.GetRequiredService())); + + services.TryAddSingleton(); + + services.TryAddSingleton(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() ?? NoOpAuditWriter.Instance; + ISecretActorAccessor? actorAccessor = sp.GetService(); + TimeProvider? timeProvider = sp.GetService(); + TimeSpan ttl = sp.GetRequiredService>().Value.ResolveCacheTtl; + + return new DefaultSecretResolver( + sp.GetRequiredService(), + sp.GetRequiredService(), + audit, + ttl, + actorAccessor, + timeProvider); + }); + + // 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())); + + // Hosted service that runs migrations on startup when SecretsOptions.RunMigrationsOnStartup. + services.AddHostedService(); + + return services; + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/SecretsOptions.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/SecretsOptions.cs new file mode 100644 index 0000000..20c779c --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/SecretsOptions.cs @@ -0,0 +1,37 @@ +using ZB.MOM.WW.Secrets.MasterKey; + +namespace ZB.MOM.WW.Secrets; + +/// +/// 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, +/// how long decrypted values are cached, and whether the UI reveal path is enabled. Bound from a +/// configuration section by . +/// +public sealed class SecretsOptions +{ + /// The path to the SQLite database backing the secret store. Defaults to secrets.db. + public string SqlitePath { get; set; } = "secrets.db"; + + /// The master-key (KEK) resolution configuration. Defaults to a fresh . + public MasterKeyOptions MasterKey { get; set; } = new(); + + /// + /// When (the default), the SQLite schema migration runs at application + /// startup via the hosted service. The migration is idempotent, so repeated restarts are safe. + /// + public bool RunMigrationsOnStartup { get; set; } = true; + + /// + /// 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. + /// + public TimeSpan ResolveCacheTtl { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Opt-in switch for the UI reveal path (consumed by the deferred ZB.MOM.WW.Secrets.Ui + /// package). Defaults to — secrets are write-only from the UI unless a + /// deployment explicitly opts in. + /// + public bool RevealEnabled { get; set; } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DependencyInjection/AddZbSecretsTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DependencyInjection/AddZbSecretsTests.cs new file mode 100644 index 0000000..a9542ed --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DependencyInjection/AddZbSecretsTests.cs @@ -0,0 +1,116 @@ +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; + +/// +/// Verifies that wires the whole +/// core: every seam resolves, a real secret round-trips through the composed graph, and a missing +/// master key fails closed. +/// +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 + { + ["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()); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + + ISecretReplicator? replicator = provider.GetService(); + Assert.NotNull(replicator); + Assert.IsType(replicator); + } + 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().MigrateAsync(CancellationToken.None); + + var name = new SecretName("db/password"); + const string plaintext = "super-secret-value"; + + ISecretCipher cipher = provider.GetRequiredService(); + ISecretStore store = provider.GetRequiredService(); + StoredSecret row = cipher.Encrypt(name, plaintext, SecretContentType.Text); + await store.UpsertAsync(row, CancellationToken.None); + + ISecretResolver resolver = provider.GetRequiredService(); + 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(); + + Assert.Throws(() => keyProvider.GetMasterKey()); + } + finally + { + File.Delete(dbPath); + } + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj index 89c1e1b..e96d479 100644 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj @@ -10,6 +10,8 @@ + + From d7f8ca455b64a2b1f7d6d1816f02b1f94589f75b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 17:10:40 -0400 Subject: [PATCH 13/20] feat(secrets-ui): RCL + authz policies + metadata-only secrets list --- .../ZB.MOM.WW.Secrets.Ui/AssemblyMarker.cs | 9 -- .../Components/SecretsList.razor | 84 ++++++++++++ .../Pages/SecretsPage.razor | 9 ++ .../SecretsAuthorization.cs | 74 +++++++++++ .../src/ZB.MOM.WW.Secrets.Ui/_Imports.razor | 7 + .../PlaceholderTests.cs | 7 - .../SecretsListTests.cs | 120 ++++++++++++++++++ 7 files changed, 294 insertions(+), 16 deletions(-) delete mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/AssemblyMarker.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretsList.razor create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Pages/SecretsPage.razor create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/SecretsAuthorization.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/_Imports.razor delete mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/PlaceholderTests.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretsListTests.cs diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/AssemblyMarker.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/AssemblyMarker.cs deleted file mode 100644 index 4c5bad5..0000000 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/AssemblyMarker.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace ZB.MOM.WW.Secrets.Ui; - -/// -/// Internal marker giving the assembly a compilable input while the Blazor -/// components are scaffolded. Replace with real components as they are added. -/// -internal static class AssemblyMarker -{ -} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretsList.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretsList.razor new file mode 100644 index 0000000..a7b2511 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretsList.razor @@ -0,0 +1,84 @@ +@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. *@ + + + @if (_secrets is null) + { +

Loading…

+ } + else + { +
+ + + + + + + + + + + + + + + @if (_secrets.Count == 0) + { + + + + } + else + { + @foreach (SecretMetadata secret in _secrets) + { + + + + + + + + + + + } + } + +
NameDescriptionContent typeKEKRevisionUpdated (UTC)Updated by
No secrets.
@secret.Name.Value@secret.Description@secret.ContentType@secret.KekId@secret.Revision@secret.UpdatedUtc.UtcDateTime.ToString("u")@secret.UpdatedBy + + @* Placeholder — actual reveal wiring is a later task. Gated so it + renders only for principals holding the reveal policy. *@ + + +
+
+ } +
+ +@code { + private readonly CancellationTokenSource _cts = new(); + private IReadOnlyList? _secrets; + + /// + protected override async Task OnInitializedAsync() + { + _secrets = await Store.ListAsync(includeDeleted: false, _cts.Token); + } + + /// + public void Dispose() + { + _cts.Cancel(); + _cts.Dispose(); + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Pages/SecretsPage.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Pages/SecretsPage.razor new file mode 100644 index 0000000..addfb5d --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Pages/SecretsPage.razor @@ -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. *@ + +

Secrets

+

Manage stored secrets. Values are never shown here — reveal is a separate, more privileged action.

+ + diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/SecretsAuthorization.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/SecretsAuthorization.cs new file mode 100644 index 0000000..719885e --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/SecretsAuthorization.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Authorization; +using ZB.MOM.WW.Auth.AspNetCore; + +namespace ZB.MOM.WW.Secrets.Ui; + +/// +/// Authorization policies and the roles that back them for the secrets-management UI. +/// +/// +/// Two policies gate the UI, layered by privilege: +/// +/// +/// +/// — view and administer secret metadata (the list +/// page, create/rename/delete affordances). Satisfied by , +/// , or . +/// +/// +/// +/// +/// — the strictly more privileged right to reveal a secret's +/// plaintext value. Satisfied only by or . +/// Because both reveal-granting roles also satisfy , holding +/// reveal always implies manage. +/// +/// +/// +/// Both policies require an authenticated user. The requirements are expressed as role checks +/// against the canonical claim, so the host maps its LDAP +/// groups to these role names (via its IGroupRoleMapper / Security:Ldap:GroupToRole +/// configuration) exactly as it does for every other ZB.MOM.WW 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 . +/// +public static class SecretsAuthorization +{ + /// Policy name for administering secret metadata (the list page and management actions). + public const string ManagePolicy = "secrets:manage"; + + /// Policy name for revealing a secret's plaintext value — strictly more privileged than . + public const string RevealPolicy = "secrets:reveal"; + + /// Role that grants secret metadata management (satisfies ). + public const string ManageRole = "secrets-manager"; + + /// Role that grants secret value reveal (satisfies both and ). + public const string RevealRole = "secrets-reveal"; + + /// Administrator role that satisfies every secrets policy. + public const string AdminRole = "administrator"; + + /// + /// Registers the and policies on the + /// supplied . Each policy requires an authenticated user; + /// is a strict superset-privilege of . + /// + /// The authorization options to add the policies to. + /// The same instance, to allow chaining. + /// is null. + 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; + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/_Imports.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/_Imports.razor new file mode 100644 index 0000000..39c22eb --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/_Imports.razor @@ -0,0 +1,7 @@ +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Authorization +@using ZB.MOM.WW.Theme +@using ZB.MOM.WW.Secrets.Abstractions +@using ZB.MOM.WW.Secrets.Ui diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/PlaceholderTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/PlaceholderTests.cs deleted file mode 100644 index 24ecffb..0000000 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/PlaceholderTests.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace ZB.MOM.WW.Secrets.Ui.Tests; - -public class PlaceholderTests -{ - [Fact] - public void Builds() => Assert.True(true); -} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretsListTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretsListTests.cs new file mode 100644 index 0000000..4f91b3b --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretsListTests.cs @@ -0,0 +1,120 @@ +using Bunit; +using Bunit.TestDoubles; +using Microsoft.Extensions.DependencyInjection; +using ZB.MOM.WW.Secrets.Abstractions; +using ZB.MOM.WW.Secrets.Ui; + +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) + => Services.AddSingleton(new FakeSecretStore(rows)); + + [Fact] + public void Renders_Metadata_ForEachSecret() + { + RegisterStore(SecretOne, SecretTwo); + var auth = this.AddTestAuthorization(); + auth.SetAuthorized("alice"); + auth.SetPolicies(SecretsAuthorization.ManagePolicy); + + var cut = RenderComponent(); + 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(); + + 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(); + + // 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(); + + Assert.Contains("No secrets", cut.Markup); + } + + /// Minimal hand-written whose only real behavior is . + private sealed class FakeSecretStore(IReadOnlyList rows) : ISecretStore + { + public Task> ListAsync(bool includeDeleted, CancellationToken ct) + => Task.FromResult(rows); + + public Task GetAsync(SecretName name, CancellationToken ct) + => throw new NotSupportedException(); + + public Task UpsertAsync(StoredSecret row, CancellationToken ct) + => throw new NotSupportedException(); + + public Task DeleteAsync(SecretName name, string? actor, CancellationToken ct) + => throw new NotSupportedException(); + + public Task> GetManifestAsync(CancellationToken ct) + => throw new NotSupportedException(); + + public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) + => throw new NotSupportedException(); + } +} From 21556cc1a75bba7f322151dafc9e4de009eb8f09 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 17:20:01 -0400 Subject: [PATCH 14/20] feat(secrets-ui): add/rotate/delete modal + audited gated reveal --- .../Components/ConfirmDeleteModal.razor | 95 +++++++++++ .../Components/RevealButton.razor | 110 +++++++++++++ .../Components/SecretEditor.razor | 153 ++++++++++++++++++ .../Components/SecretsList.razor | 92 +++++++++-- .../SecretActorResolver.cs | 45 ++++++ .../ZB.MOM.WW.Secrets.Ui.csproj | 1 + .../src/ZB.MOM.WW.Secrets.Ui/_Imports.razor | 3 + .../ConfirmDeleteModalTests.cs | 100 ++++++++++++ .../Fakes/CapturingAuditWriter.cs | 24 +++ .../Fakes/FakeCipher.cs | 45 ++++++ .../Fakes/RecordingSecretStore.cs | 75 +++++++++ .../RevealButtonTests.cs | 98 +++++++++++ .../SecretEditorTests.cs | 112 +++++++++++++ .../SecretsListTests.cs | 10 +- .../ZB.MOM.WW.Secrets.Ui.Tests.csproj | 1 + 15 files changed, 950 insertions(+), 14 deletions(-) create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/ConfirmDeleteModal.razor create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/RevealButton.razor create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretEditor.razor create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/SecretActorResolver.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ConfirmDeleteModalTests.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/Fakes/CapturingAuditWriter.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/Fakes/FakeCipher.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/Fakes/RecordingSecretStore.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/RevealButtonTests.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretEditorTests.cs diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/ConfirmDeleteModal.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/ConfirmDeleteModal.razor new file mode 100644 index 0000000..d5e2a12 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/ConfirmDeleteModal.razor @@ -0,0 +1,95 @@ +@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) +{ + + +} + +@code { + private readonly CancellationTokenSource _cts = new(); + private string? _error; + private bool _busy; + + /// Whether the modal overlay is rendered. + [Parameter] public bool Visible { get; set; } + + /// The secret the modal offers to delete. + [Parameter] public SecretName Name { get; set; } + + /// Raised after the secret has been tombstoned and audited. + [Parameter] public EventCallback OnDeleted { get; set; } + + /// Raised when the user cancels the deletion. + [Parameter] public EventCallback OnCancelled { get; set; } + + /// The cascading authentication state, used as the fallback source of the audit actor. + [CascadingParameter] private Task? AuthState { get; set; } + + private async Task ConfirmAsync() + { + _error = null; + _busy = true; + try + { + string actor = await SecretActorResolver.ResolveAsync(Services, AuthState); + + 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); + + await OnDeleted.InvokeAsync(); + } + finally + { + _busy = false; + } + } + + private Task CancelAsync() => OnCancelled.InvokeAsync(); + + /// + public void Dispose() + { + _cts.Cancel(); + _cts.Dispose(); + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/RevealButton.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/RevealButton.razor new file mode 100644 index 0000000..cdb5742 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/RevealButton.razor @@ -0,0 +1,110 @@ +@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) +{ + +} +else +{ +
+ @if (_value is not null) + { + @_value + } + else + { + @(_error ?? "Unavailable.") + } + +
+} + +@code { + private readonly CancellationTokenSource _cts = new(); + private bool _revealed; + private string? _value; + private string? _error; + private bool _busy; + + /// The secret whose plaintext this button reveals. + [Parameter] public SecretName Name { get; set; } + + /// The cascading authentication state, used as the fallback source of the audit actor. + [CascadingParameter] private Task? 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; + } + finally + { + _busy = false; + _revealed = true; + } + + string actor = await SecretActorResolver.ResolveAsync(Services, AuthState); + await Audit.WriteAsync( + new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTimeOffset.UtcNow, + Actor = actor, + Action = "secret.reveal", + Outcome = outcome, + Category = "Secrets", + Target = Name.Value, + }, + _cts.Token); + } + + private void Hide() + { + _revealed = false; + _value = null; + _error = null; + } + + /// + public void Dispose() + { + _cts.Cancel(); + _cts.Dispose(); + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretEditor.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretEditor.razor new file mode 100644 index 0000000..06a169d --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretEditor.razor @@ -0,0 +1,153 @@ +@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. *@ + + + + @if (IsRotate) + { + + } + else + { + + } + + + + + + + + + + + + + + + @if (_error is not null) + { +

@_error

+ } + +
+ + @(IsRotate ? "Rotate" : "Add") + + + Cancel + +
+
+ +@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; + + /// + /// The secret being rotated. When null the editor is in add mode (name is + /// editable); when set, the editor is in rotate mode (name is read-only and fixed). + /// + [Parameter] public SecretName? ExistingName { get; set; } + + /// Raised after a successful add or rotate, so a parent can refresh its listing. + [Parameter] public EventCallback OnSaved { get; set; } + + /// Raised when the user cancels the editor without saving. + [Parameter] public EventCallback OnCancelled { get; set; } + + /// The cascading authentication state, used as the fallback source of the audit actor. + [CascadingParameter] private Task? AuthState { get; set; } + + private bool IsRotate => ExistingName is not null; + + /// + protected override void OnParametersSet() + { + if (IsRotate) + { + _name = ExistingName!.Value.Value; + } + } + + private async Task SubmitAsync() + { + _error = null; + + SecretName name; + try + { + name = ExistingName ?? new SecretName(_name); + } + catch (ArgumentException ex) + { + _error = ex.Message; + return; + } + + _busy = true; + try + { + string actor = await SecretActorResolver.ResolveAsync(Services, AuthState); + + 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); + + await OnSaved.InvokeAsync(); + } + finally + { + _busy = false; + } + } + + private Task CancelAsync() => OnCancelled.InvokeAsync(); + + /// + public void Dispose() + { + _cts.Cancel(); + _cts.Dispose(); + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretsList.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretsList.razor index a7b2511..d0de9b8 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretsList.razor +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretsList.razor @@ -1,7 +1,24 @@ @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. *@ +@* 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. *@ + + +
+ + Add secret + +
+
+ +@if (_editorMode != EditorMode.None) +{ + +} @if (_secrets is null) @@ -43,16 +60,20 @@ @secret.Revision @secret.UpdatedUtc.UtcDateTime.ToString("u") @secret.UpdatedBy - + - @* Placeholder — actual reveal wiring is a later task. Gated so it - renders only for principals holding the reveal policy. *@ - + @@ -65,14 +86,59 @@ } + + @code { private readonly CancellationTokenSource _cts = new(); private IReadOnlyList? _secrets; - /// - protected override async Task OnInitializedAsync() + private EditorMode _editorMode = EditorMode.None; + private SecretName? _rotateName; + private SecretName? _deleteTarget; + + private enum EditorMode { - _secrets = await Store.ListAsync(includeDeleted: false, _cts.Token); + None, + Add, + Rotate, + } + + /// + 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(); } /// diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/SecretActorResolver.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/SecretActorResolver.cs new file mode 100644 index 0000000..d783945 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/SecretActorResolver.cs @@ -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; + +/// +/// Resolves the principal to record as the audit Actor for a UI-driven secret mutation or +/// reveal. The resolution order is: an explicitly wired (if the +/// host registered one and it reports a non-blank actor); otherwise the authenticated user's +/// from the cascading authentication state; +/// otherwise the literal "unknown" so an action is never attributed to a blank actor. +/// +internal static class SecretActorResolver +{ + /// The fallback actor used when no accessor and no authenticated identity name is available. + public const string Unknown = "unknown"; + + /// + /// Resolves the audit actor. Never returns null or blank. + /// + /// The component's service provider, used to optionally resolve an . + /// The cascading authentication-state task, or null if unavailable. + /// The resolved actor identity string. + public static async Task ResolveAsync(IServiceProvider services, Task? authState) + { + string? fromAccessor = services.GetService()?.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; + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/ZB.MOM.WW.Secrets.Ui.csproj b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/ZB.MOM.WW.Secrets.Ui.csproj index c3b2a71..9333ce5 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/ZB.MOM.WW.Secrets.Ui.csproj +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/ZB.MOM.WW.Secrets.Ui.csproj @@ -29,6 +29,7 @@ + diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/_Imports.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/_Imports.razor index 39c22eb..8aa7a01 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/_Imports.razor +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/_Imports.razor @@ -2,6 +2,9 @@ @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 diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ConfirmDeleteModalTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ConfirmDeleteModalTests.cs new file mode 100644 index 0000000..de03688 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ConfirmDeleteModalTests.cs @@ -0,0 +1,100 @@ +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(store); + Services.AddSingleton(audit); + + var name = new SecretName("db/primary"); + var auth = this.AddTestAuthorization(); + auth.SetAuthorized("carol"); + auth.SetPolicies(SecretsAuthorization.ManagePolicy); + + bool deleted = false; + var cut = RenderComponent(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(store); + Services.AddSingleton(audit); + + var name = new SecretName("db/primary"); + var auth = this.AddTestAuthorization(); + auth.SetAuthorized("carol"); + auth.SetPolicies(SecretsAuthorization.ManagePolicy); + + bool cancelled = false; + var cut = RenderComponent(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_NotVisible_RendersNothing() + { + var store = new RecordingSecretStore(); + var audit = new CapturingAuditWriter(); + Services.AddSingleton(store); + Services.AddSingleton(audit); + + var name = new SecretName("db/primary"); + this.AddTestAuthorization().SetAuthorized("carol"); + + var cut = RenderComponent(p => p + .Add(c => c.Visible, false) + .Add(c => c.Name, name)); + + Assert.Empty(cut.FindAll(".modal")); + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/Fakes/CapturingAuditWriter.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/Fakes/CapturingAuditWriter.cs new file mode 100644 index 0000000..e94d4ce --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/Fakes/CapturingAuditWriter.cs @@ -0,0 +1,24 @@ +using System.Collections.Concurrent; +using ZB.MOM.WW.Audit; + +namespace ZB.MOM.WW.Secrets.Ui.Tests.Fakes; + +/// +/// An test double that collects every 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. +/// +public sealed class CapturingAuditWriter : IAuditWriter +{ + private readonly ConcurrentQueue _events = new(); + + /// The events captured so far, in write order. + public IReadOnlyList Events => _events.ToArray(); + + /// + public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) + { + _events.Enqueue(evt); + return Task.CompletedTask; + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/Fakes/FakeCipher.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/Fakes/FakeCipher.cs new file mode 100644 index 0000000..fb13a8d --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/Fakes/FakeCipher.cs @@ -0,0 +1,45 @@ +using System.Text; +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.Secrets.Ui.Tests.Fakes; + +/// +/// A trivial, reversible stand-in for UI tests. It does not perform real +/// cryptography — it simply carries the UTF-8 plaintext in so a +/// test can prove the editor's encrypt→upsert round-trips back to the entered value. Never used in +/// production. A flag lets a test drive the decrypt-failure reveal path. +/// +public sealed class FakeCipher : ISecretCipher +{ + /// When true, throws to exercise the failure path. + public bool FailDecrypt { get; set; } + + /// + 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, + }; + + /// + public string Decrypt(StoredSecret secret) + { + if (FailDecrypt) + { + throw new SecretDecryptionException("Fake decrypt failure."); + } + + return Encoding.UTF8.GetString(secret.Ciphertext); + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/Fakes/RecordingSecretStore.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/Fakes/RecordingSecretStore.cs new file mode 100644 index 0000000..7b75f8d --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/Fakes/RecordingSecretStore.cs @@ -0,0 +1,75 @@ +using System.Collections.Concurrent; +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.Secrets.Ui.Tests.Fakes; + +/// +/// A hand-written test double (the family does not use Moq) that keeps +/// an in-memory table of rows and records every and +/// call so a test can assert on the mutation trail. +/// +public sealed class RecordingSecretStore : ISecretStore +{ + private readonly ConcurrentDictionary _rows = new(); + private readonly ConcurrentQueue _upserts = new(); + private readonly ConcurrentQueue<(SecretName Name, string? Actor)> _deletes = new(); + + /// Rows upserted, in call order. + public IReadOnlyList Upserts => _upserts.ToArray(); + + /// Delete calls (name + recorded actor), in call order. + public IReadOnlyList<(SecretName Name, string? Actor)> Deletes => _deletes.ToArray(); + + /// Seeds a row so and can return it. + /// The row to seed. + public void Seed(StoredSecret row) => _rows[row.Name.Value] = row; + + /// + public Task GetAsync(SecretName name, CancellationToken ct) + => Task.FromResult(_rows.TryGetValue(name.Value, out StoredSecret? row) ? row : null); + + /// + public Task UpsertAsync(StoredSecret row, CancellationToken ct) + { + _rows[row.Name.Value] = row; + _upserts.Enqueue(row); + return Task.CompletedTask; + } + + /// + public Task DeleteAsync(SecretName name, string? actor, CancellationToken ct) + { + _deletes.Enqueue((name, actor)); + return Task.FromResult(_rows.TryRemove(name.Value, out _)); + } + + /// + public Task> ListAsync(bool includeDeleted, CancellationToken ct) + { + IReadOnlyList 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); + } + + /// + public Task> GetManifestAsync(CancellationToken ct) + => throw new NotSupportedException(); + + /// + public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) + => throw new NotSupportedException(); +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/RevealButtonTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/RevealButtonTests.cs new file mode 100644 index 0000000..bd52d09 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/RevealButtonTests.cs @@ -0,0 +1,98 @@ +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(store); + Services.AddSingleton(audit); + Services.AddSingleton(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(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(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); + } + + 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); + } + } + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretEditorTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretEditorTests.cs new file mode 100644 index 0000000..a51bc7b --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretEditorTests.cs @@ -0,0 +1,112 @@ +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(store); + Services.AddSingleton(audit); + Services.AddSingleton(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(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(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"); + } + + 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); + } + } + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretsListTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretsListTests.cs index 4f91b3b..673123e 100644 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretsListTests.cs +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretsListTests.cs @@ -1,8 +1,10 @@ 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; @@ -33,7 +35,13 @@ public class SecretsListTests : TestContext }; private void RegisterStore(params SecretMetadata[] rows) - => Services.AddSingleton(new FakeSecretStore(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(new FakeSecretStore(rows)); + Services.AddSingleton(new FakeCipher()); + Services.AddSingleton(new CapturingAuditWriter()); + } [Fact] public void Renders_Metadata_ForEachSecret() diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ZB.MOM.WW.Secrets.Ui.Tests.csproj b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ZB.MOM.WW.Secrets.Ui.Tests.csproj index a6d9697..b1fdffd 100644 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ZB.MOM.WW.Secrets.Ui.Tests.csproj +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ZB.MOM.WW.Secrets.Ui.Tests.csproj @@ -10,6 +10,7 @@ + From 0ab276dac09f689778ac5e6a0ea1e6e036fe0e6c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 17:26:14 -0400 Subject: [PATCH 15/20] feat(secrets-cli): set/get/list/rm/rotate commands --- ZB.MOM.WW.Secrets/Directory.Packages.props | 1 + .../src/ZB.MOM.WW.Secrets.Cli/Program.cs | 155 +++++++++++++++- .../ZB.MOM.WW.Secrets.Cli/SecretCommands.cs | 174 ++++++++++++++++++ .../ZB.MOM.WW.Secrets.Cli.csproj | 10 + .../ZB.MOM.WW.Secrets.Cli/appsettings.json | 10 + .../Cli/SecretCommandsTests.cs | 156 ++++++++++++++++ .../ZB.MOM.WW.Secrets.Tests.csproj | 1 + 7 files changed, 504 insertions(+), 3 deletions(-) create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/SecretCommands.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/appsettings.json create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/SecretCommandsTests.cs diff --git a/ZB.MOM.WW.Secrets/Directory.Packages.props b/ZB.MOM.WW.Secrets/Directory.Packages.props index 84dca02..e8fbd27 100644 --- a/ZB.MOM.WW.Secrets/Directory.Packages.props +++ b/ZB.MOM.WW.Secrets/Directory.Packages.props @@ -22,6 +22,7 @@ + diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Program.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Program.cs index 44ac559..465639d 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Program.cs +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Program.cs @@ -1,3 +1,152 @@ -// Placeholder entry point for the ZB.MOM.WW.Secrets CLI. Real commands are added -// in a later task; for now it is a no-op that exits successfully. -return 0; +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() + .MigrateAsync(CancellationToken.None); + +var commands = new SecretCommands( + host.Services.GetRequiredService(), + host.Services.GetRequiredService(), + host.Services.GetRequiredService(), + 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 and ."); + (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 and ."); + (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 ."); + 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 ."); + 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 [args] + + set [--content-type text|connection-string|json|binary-base64] [--description "..."] + rotate [--content-type ...] [--description "..."] + get + list [--include-deleted] + rm + """); + return 2; +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/SecretCommands.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/SecretCommands.cs new file mode 100644 index 0000000..5a6ede4 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/SecretCommands.cs @@ -0,0 +1,174 @@ +using System.Text.Json; +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.Secrets.Cli; + +/// +/// The thin, front-end-agnostic command layer behind the secret CLI verbs +/// (set / get / list / rm / rotate). Each verb is an +/// -returning task (0 on success, non-zero on error) and writes ALL output to the +/// injected — never to 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 +/// , which intentionally prints the decrypted plaintext (see its remarks). +/// +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; + + /// Creates the command set over the supplied core seams and output sink. + /// The encrypted store (upsert / delete / list). + /// The envelope cipher used to seal values on set / rotate. + /// The read seam used by get to resolve current plaintext. + /// Sink for all command output (JSON confirmations, listings, values). + 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)); + } + + /// + /// set: seals 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. + /// + /// The secret name. + /// The plaintext to seal. + /// How consumers should interpret the plaintext. + /// Optional human-readable description. + /// Principal recorded as created_by / updated_by, if known. + /// A token to cancel the operation. + /// 0 on success. + public async Task 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; + } + + /// + /// rotate: like , 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. + /// + /// The existing secret to rotate. + /// The new plaintext to seal in place. + /// How consumers should interpret the plaintext. + /// Optional human-readable description. + /// Principal recorded as updated_by, if known. + /// A token to cancel the operation. + /// 0 on success; non-zero if the secret does not exist. + public async Task 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; + } + + /// + /// get: resolves and prints the current DECRYPTED plaintext for to the + /// output sink. + /// + /// + /// This deliberately exposes the secret value. 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 not-found + /// error and returns non-zero. + /// + /// The secret to resolve. + /// A token to cancel the operation. + /// 0 when the value was printed; non-zero when the secret is absent. + public async Task 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; + } + + /// + /// 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. + /// + /// When , tombstoned rows are included. + /// A token to cancel the operation. + /// 0 on success. + public async Task ListAsync(bool includeDeleted, CancellationToken ct) + { + IReadOnlyList 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; + } + + /// + /// rm: soft-deletes (tombstones) and prints a JSON confirmation with a + /// found flag. + /// + /// The secret to tombstone. + /// Principal recorded as updated_by, if known. + /// A token to cancel the operation. + /// 0 if a row was tombstoned; non-zero if no such row existed. + public async Task 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; + } + + /// Seals a value under the cipher and upserts it, carrying description + actor stamps. + 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); + } + + /// Serializes to compact JSON on its own line. + private void WriteJson(object value) => _output.WriteLine(JsonSerializer.Serialize(value, JsonOptions)); +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj index 063ae94..3302fcc 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj @@ -8,8 +8,18 @@ false + + + + + + + PreserveNewest + + + diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/appsettings.json b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/appsettings.json new file mode 100644 index 0000000..8f8dcf5 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/appsettings.json @@ -0,0 +1,10 @@ +{ + "Secrets": { + "SqlitePath": "secrets.db", + "MasterKey": { + "Source": "Environment", + "EnvVarName": "ZB_SECRETS_MASTER_KEY" + }, + "RunMigrationsOnStartup": true + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/SecretCommandsTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/SecretCommandsTests.cs new file mode 100644 index 0000000..5285464 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/SecretCommandsTests.cs @@ -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; + +/// +/// Exercises the headless 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. +/// +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()); + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj index e96d479..5094891 100644 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj @@ -21,6 +21,7 @@ + From 294f53b49aa83209f5c02774037b1b4ce5fd63d3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 17:31:34 -0400 Subject: [PATCH 16/20] fix(secrets-ui): preserve metadata on rotate + audit unexpected mutation/reveal failures --- .../ISecretActorAccessor.cs | 10 ++- .../Components/ConfirmDeleteModal.razor | 52 ++++++++++-- .../Components/RevealButton.razor | 41 ++++++--- .../Components/SecretEditor.razor | 85 +++++++++++++++---- .../ConfirmDeleteModalTests.cs | 38 +++++++++ .../Fakes/RecordingSecretStore.cs | 16 ++++ .../RevealButtonTests.cs | 29 +++++++ .../SecretEditorTests.cs | 72 ++++++++++++++++ 8 files changed, 306 insertions(+), 37 deletions(-) diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretActorAccessor.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretActorAccessor.cs index 9f8f1dc..5c4dfed 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretActorAccessor.cs +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretActorAccessor.cs @@ -3,15 +3,17 @@ namespace ZB.MOM.WW.Secrets.Abstractions; /// /// 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. A null -/// (or no accessor at all) is treated as the "system" actor by the resolver, so keyless / -/// background resolutions are still attributed rather than left blank. +/// context accessor, an actor context, a CLI identity, etc. When is +/// null (or no accessor is registered), the fallback actor string is consumer-defined so an +/// action is still attributed rather than left blank — e.g. "system" for background/keyless +/// resolution, or "unknown" for an unauthenticated UI action (the value the UI's +/// SecretActorResolver uses). /// public interface ISecretActorAccessor { /// /// The current principal to record as the audit Actor, or null when no - /// principal is available (treated as "system"). + /// principal is available (the resolver then substitutes its consumer-defined fallback actor). /// string? CurrentActor { get; } } diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/ConfirmDeleteModal.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/ConfirmDeleteModal.razor index d5e2a12..2fe91de 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/ConfirmDeleteModal.razor +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/ConfirmDeleteModal.razor @@ -57,12 +57,53 @@ { _error = null; _busy = true; + bool deleted = false; try { string actor = await SecretActorResolver.ResolveAsync(Services, AuthState); - await Store.DeleteAsync(Name, actor, _cts.Token); + 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) + { + await OnDeleted.InvokeAsync(); + } + } + + private async Task WriteFailureAuditAsync(string actor) + { + try + { await Audit.WriteAsync( new AuditEvent { @@ -70,17 +111,16 @@ OccurredAtUtc = DateTimeOffset.UtcNow, Actor = actor, Action = "secret.delete", - Outcome = AuditOutcome.Success, + Outcome = AuditOutcome.Failure, Category = "Secrets", Target = Name.Value, + DetailsJson = "{\"reason\":\"error\"}", }, _cts.Token); - - await OnDeleted.InvokeAsync(); } - finally + catch { - _busy = false; + // Best-effort audit of a failure; never let audit itself surface into the event pipeline. } } diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/RevealButton.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/RevealButton.razor index cdb5742..378a1c8 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/RevealButton.razor +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/RevealButton.razor @@ -73,6 +73,15 @@ else _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; @@ -80,18 +89,26 @@ else } string actor = await SecretActorResolver.ResolveAsync(Services, AuthState); - await Audit.WriteAsync( - new AuditEvent - { - EventId = Guid.NewGuid(), - OccurredAtUtc = DateTimeOffset.UtcNow, - Actor = actor, - Action = "secret.reveal", - Outcome = outcome, - Category = "Secrets", - Target = Name.Value, - }, - _cts.Token); + 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() diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretEditor.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretEditor.razor index 06a169d..62567cb 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretEditor.razor +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretEditor.razor @@ -82,13 +82,26 @@ [CascadingParameter] private Task? AuthState { get; set; } private bool IsRotate => ExistingName is not null; + private bool _metadataLoaded; /// - protected override void OnParametersSet() + 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; + } } } @@ -108,37 +121,79 @@ } _busy = true; + bool saved = false; try { string actor = await SecretActorResolver.ResolveAsync(Services, AuthState); - StoredSecret row = Cipher.Encrypt(name, _value, _contentType); - row = row with + try { - Description = string.IsNullOrWhiteSpace(_description) ? null : _description, - CreatedBy = actor, - UpdatedBy = actor, - }; - await Store.UpsertAsync(row, _cts.Token); + 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) + { + 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 = IsRotate ? "secret.rotate" : "secret.add", - Outcome = AuditOutcome.Success, + Action = action, + Outcome = AuditOutcome.Failure, Category = "Secrets", - Target = name.Value, + Target = target, + DetailsJson = "{\"reason\":\"error\"}", }, _cts.Token); - - await OnSaved.InvokeAsync(); } - finally + catch { - _busy = false; + // Best-effort audit of a failure; never let audit itself surface into the event pipeline. } } diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ConfirmDeleteModalTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ConfirmDeleteModalTests.cs index de03688..775cf27 100644 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ConfirmDeleteModalTests.cs +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ConfirmDeleteModalTests.cs @@ -80,6 +80,44 @@ public class ConfirmDeleteModalTests : TestContext 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(store); + Services.AddSingleton(audit); + + var name = new SecretName("db/primary"); + var auth = this.AddTestAuthorization(); + auth.SetAuthorized("carol"); + auth.SetPolicies(SecretsAuthorization.ManagePolicy); + + bool deleted = false; + var cut = RenderComponent(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() { diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/Fakes/RecordingSecretStore.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/Fakes/RecordingSecretStore.cs index 7b75f8d..3f274b1 100644 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/Fakes/RecordingSecretStore.cs +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/Fakes/RecordingSecretStore.cs @@ -20,6 +20,12 @@ public sealed class RecordingSecretStore : ISecretStore /// Delete calls (name + recorded actor), in call order. public IReadOnlyList<(SecretName Name, string? Actor)> Deletes => _deletes.ToArray(); + /// When set, throws this to drive the mutation-failure path. + public Exception? UpsertFault { get; set; } + + /// When set, throws this to drive the mutation-failure path. + public Exception? DeleteFault { get; set; } + /// Seeds a row so and can return it. /// The row to seed. public void Seed(StoredSecret row) => _rows[row.Name.Value] = row; @@ -31,6 +37,11 @@ public sealed class RecordingSecretStore : ISecretStore /// 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; @@ -39,6 +50,11 @@ public sealed class RecordingSecretStore : ISecretStore /// public Task 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 _)); } diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/RevealButtonTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/RevealButtonTests.cs index bd52d09..3f5ff46 100644 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/RevealButtonTests.cs +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/RevealButtonTests.cs @@ -85,6 +85,35 @@ public class RevealButtonTests : TestContext 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(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() }) diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretEditorTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretEditorTests.cs index a51bc7b..f807ae6 100644 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretEditorTests.cs +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretEditorTests.cs @@ -99,6 +99,78 @@ public class SecretEditorTests : TestContext 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(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(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() }) From e20812cbae8774fc1abfa9a8a9a966cf57d6b072 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 17:39:55 -0400 Subject: [PATCH 17/20] chore(secrets): wire cache invalidation on rotate/delete, drop dead RevealEnabled, remove scaffold leftovers --- .../ISecretCacheInvalidator.cs | 8 ++++++ .../Components/ConfirmDeleteModal.razor | 5 ++++ .../Components/SecretEditor.razor | 5 ++++ .../src/ZB.MOM.WW.Secrets/AssemblyMarker.cs | 9 ------- .../DefaultSecretResolver.cs | 6 ++--- .../SecretsServiceCollectionExtensions.cs | 8 +++++- .../src/ZB.MOM.WW.Secrets/SecretsOptions.cs | 11 ++------ .../DefaultSecretResolverTests.cs | 17 ++++++++++++ .../DependencyInjection/AddZbSecretsTests.cs | 27 +++++++++++++++++++ .../PlaceholderTests.cs | 7 ----- 10 files changed, 74 insertions(+), 29 deletions(-) create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretCacheInvalidator.cs delete mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/AssemblyMarker.cs delete mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/PlaceholderTests.cs diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretCacheInvalidator.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretCacheInvalidator.cs new file mode 100644 index 0000000..5433171 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretCacheInvalidator.cs @@ -0,0 +1,8 @@ +namespace ZB.MOM.WW.Secrets.Abstractions; + +/// Evicts a cached decrypted secret so the next resolve re-reads it. Called by write paths after a rotate/delete. +public interface ISecretCacheInvalidator +{ + /// Evicts the cache entry for (no-op if absent). + void Invalidate(SecretName name); +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/ConfirmDeleteModal.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/ConfirmDeleteModal.razor index 2fe91de..c5b7996 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/ConfirmDeleteModal.razor +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/ConfirmDeleteModal.razor @@ -96,6 +96,11 @@ 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()?.Invalidate(Name); + await OnDeleted.InvokeAsync(); } } diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretEditor.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretEditor.razor index 62567cb..0a51c0a 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretEditor.razor +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretEditor.razor @@ -169,6 +169,11 @@ 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()?.Invalidate(name); + await OnSaved.InvokeAsync(); } } diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/AssemblyMarker.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/AssemblyMarker.cs deleted file mode 100644 index 4217325..0000000 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/AssemblyMarker.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace ZB.MOM.WW.Secrets; - -/// -/// Internal marker giving the assembly a compilable input while the store and -/// resolver are scaffolded. Replace with real implementation as it is added. -/// -internal static class AssemblyMarker -{ -} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DefaultSecretResolver.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DefaultSecretResolver.cs index 6875fc7..7152b91 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DefaultSecretResolver.cs +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DefaultSecretResolver.cs @@ -32,7 +32,7 @@ namespace ZB.MOM.WW.Secrets; /// ciphertext and any value are never included. /// /// -public sealed class DefaultSecretResolver : ISecretResolver +public sealed class DefaultSecretResolver : ISecretResolver, ISecretCacheInvalidator { private const string SystemActor = "system"; private const string AuditCategory = "Secrets"; @@ -125,8 +125,8 @@ public sealed class DefaultSecretResolver : ISecretResolver /// /// Evicts the cached plaintext for , if any. The admin / write path - /// calls this immediately after a rotate or delete so a stale value is not served for the - /// remainder of its TTL. Not part of ; cheap and idempotent. + /// calls this (via ) immediately after a rotate or delete + /// so a stale value is not served for the remainder of its TTL. Cheap and idempotent. /// /// The secret whose cache entry to remove. public void Invalidate(SecretName name) => _cache.TryRemove(name.Value, out _); diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs index 4d73a5b..20c7187 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs @@ -56,7 +56,10 @@ public static class SecretsServiceCollectionExtensions services.TryAddSingleton(); - services.TryAddSingleton(sp => + // 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(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. @@ -74,6 +77,9 @@ public static class SecretsServiceCollectionExtensions timeProvider); }); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => sp.GetRequiredService()); + // Migrator: singleton, constructed from the already-registered connection factory. Needed // before any store operations so the schema exists. services.TryAddSingleton(sp => diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/SecretsOptions.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/SecretsOptions.cs index 20c779c..5d5ee34 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/SecretsOptions.cs +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/SecretsOptions.cs @@ -4,8 +4,8 @@ namespace ZB.MOM.WW.Secrets; /// /// 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, -/// how long decrypted values are cached, and whether the UI reveal path is enabled. Bound from a +/// 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 . /// public sealed class SecretsOptions @@ -27,11 +27,4 @@ public sealed class SecretsOptions /// default (30 seconds) because the cache holds plaintext secret material. /// public TimeSpan ResolveCacheTtl { get; set; } = TimeSpan.FromSeconds(30); - - /// - /// Opt-in switch for the UI reveal path (consumed by the deferred ZB.MOM.WW.Secrets.Ui - /// package). Defaults to — secrets are write-only from the UI unless a - /// deployment explicitly opts in. - /// - public bool RevealEnabled { get; set; } } diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DefaultSecretResolverTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DefaultSecretResolverTests.cs index 631b2fe..1c0c24f 100644 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DefaultSecretResolverTests.cs +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DefaultSecretResolverTests.cs @@ -163,6 +163,23 @@ public class DefaultSecretResolverTests 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() { diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DependencyInjection/AddZbSecretsTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DependencyInjection/AddZbSecretsTests.cs index a9542ed..bea5dff 100644 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DependencyInjection/AddZbSecretsTests.cs +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DependencyInjection/AddZbSecretsTests.cs @@ -57,6 +57,33 @@ public sealed class AddZbSecretsTests } } + [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(); + var invalidator = provider.GetService(); + + 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() { diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/PlaceholderTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/PlaceholderTests.cs deleted file mode 100644 index 8464965..0000000 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/PlaceholderTests.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace ZB.MOM.WW.Secrets.Tests; - -public class PlaceholderTests -{ - [Fact] - public void Builds() => Assert.True(true); -} From 2c64683303213fc9661db770621460c34c128c3b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 04:00:32 -0400 Subject: [PATCH 18/20] docs(secrets): package README + embed in nupkgs --- ZB.MOM.WW.Secrets/Directory.Build.props | 6 +++ ZB.MOM.WW.Secrets/README.md | 67 +++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 ZB.MOM.WW.Secrets/README.md diff --git a/ZB.MOM.WW.Secrets/Directory.Build.props b/ZB.MOM.WW.Secrets/Directory.Build.props index c4755a6..3d9b855 100644 --- a/ZB.MOM.WW.Secrets/Directory.Build.props +++ b/ZB.MOM.WW.Secrets/Directory.Build.props @@ -7,6 +7,12 @@ latest 0.1.0 true + README.md + + + + + diff --git a/ZB.MOM.WW.Secrets/README.md b/ZB.MOM.WW.Secrets/README.md new file mode 100644 index 0000000..4a7ec36 --- /dev/null +++ b/ZB.MOM.WW.Secrets/README.md @@ -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()); +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. From 76124590f018f67ce900b09cc50e291a9e31e5c4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 04:11:20 -0400 Subject: [PATCH 19/20] =?UTF-8?q?fix(secrets-ui):=20admin=20role=20must=20?= =?UTF-8?q?be=20canonical=20'Administrator'=20(case-sensitive)=20=E2=80=94?= =?UTF-8?q?=200.1.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reference-consumer (HistorianGateway) caught that SecretsAuthorization.AdminRole was 'administrator' (lowercase) while the family canonical role is CanonicalRole.Administrator. ASP.NET role claims compare case-sensitively (Ordinal), so a real admin got 403 on the secrets UI. Now nameof(CanonicalRole.Administrator). --- ZB.MOM.WW.Secrets/Directory.Build.props | 2 +- .../src/ZB.MOM.WW.Secrets.Ui/SecretsAuthorization.cs | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/ZB.MOM.WW.Secrets/Directory.Build.props b/ZB.MOM.WW.Secrets/Directory.Build.props index 3d9b855..456ba04 100644 --- a/ZB.MOM.WW.Secrets/Directory.Build.props +++ b/ZB.MOM.WW.Secrets/Directory.Build.props @@ -5,7 +5,7 @@ enable enable latest - 0.1.0 + 0.1.1 true README.md diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/SecretsAuthorization.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/SecretsAuthorization.cs index 719885e..df96355 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/SecretsAuthorization.cs +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/SecretsAuthorization.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Authorization; +using ZB.MOM.WW.Auth.Abstractions.Roles; using ZB.MOM.WW.Auth.AspNetCore; namespace ZB.MOM.WW.Secrets.Ui; @@ -46,8 +47,13 @@ public static class SecretsAuthorization /// Role that grants secret value reveal (satisfies both and ). public const string RevealRole = "secrets-reveal"; - /// Administrator role that satisfies every secrets policy. - public const string AdminRole = "administrator"; + /// + /// Administrator role that satisfies every secrets policy. Uses the family's canonical + /// 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. + /// + public const string AdminRole = nameof(CanonicalRole.Administrator); /// /// Registers the and policies on the From 9bd3c76d960c6f40d9531d86c0128e28c7ea4cd5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 04:16:25 -0400 Subject: [PATCH 20/20] =?UTF-8?q?fix(secrets):=20${secret:}=20expander=20s?= =?UTF-8?q?kips=20comment=20keys=20(=5F-prefixed=20leaf)=20=E2=80=94=200.1?= =?UTF-8?q?.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live HistorianGateway boot caught that the expander scanned a '_secretsComment' appsettings value documenting the ${secret:...} syntax, tried to resolve the literal example token, and crashed boot. The family uses _*Comment keys pervasively, so skip any config key whose leaf segment starts with '_' (the _comment convention). Functional keys never lead with '_'. --- ZB.MOM.WW.Secrets/Directory.Build.props | 2 +- .../Configuration/SecretReferenceExpander.cs | 21 ++++++++++++++- .../SecretReferenceExpanderTests.cs | 26 +++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/ZB.MOM.WW.Secrets/Directory.Build.props b/ZB.MOM.WW.Secrets/Directory.Build.props index 456ba04..974d6cf 100644 --- a/ZB.MOM.WW.Secrets/Directory.Build.props +++ b/ZB.MOM.WW.Secrets/Directory.Build.props @@ -5,7 +5,7 @@ enable enable latest - 0.1.1 + 0.1.2 true README.md diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Configuration/SecretReferenceExpander.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Configuration/SecretReferenceExpander.cs index 2edf0ac..acecf37 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Configuration/SecretReferenceExpander.cs +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Configuration/SecretReferenceExpander.cs @@ -83,6 +83,13 @@ public sealed class SecretReferenceExpander /// or ValidateOnStart executes; otherwise validators may see the unexpanded placeholder. /// Fail-closed: a missing referenced secret propagates out /// of startup. + /// + /// Documentation/comment keys are skipped: any entry whose leaf key segment (the part after the + /// last :) begins with an underscore — the widely-used "_comment" JSON convention — + /// is left untouched. This lets an appsettings file document the ${secret:...} syntax in a + /// _comment 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. + /// /// /// The loaded configuration root to mutate. /// A token to cancel the operation. @@ -95,7 +102,9 @@ public sealed class SecretReferenceExpander var pending = new List>(); foreach (KeyValuePair entry in config.AsEnumerable()) { - if (entry.Value is { } value && value.Contains(TokenMarker, StringComparison.Ordinal)) + if (entry.Value is { } value + && value.Contains(TokenMarker, StringComparison.Ordinal) + && !IsCommentKey(entry.Key)) { pending.Add(new KeyValuePair(entry.Key, value)); } @@ -106,4 +115,14 @@ public sealed class SecretReferenceExpander 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('_'); + } } diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretReferenceExpanderTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretReferenceExpanderTests.cs index dd868f8..bcc2eb8 100644 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretReferenceExpanderTests.cs +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretReferenceExpanderTests.cs @@ -103,4 +103,30 @@ public sealed class SecretReferenceExpanderTests await Assert.ThrowsAsync( () => 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 + { + ["_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"]); + } }