From d78b53304545d10af414dc1271fb304af1b45bbc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 16:28:11 -0400 Subject: [PATCH] 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)); +}