feat(secrets): abstractions value types, enums, exceptions

This commit is contained in:
Joseph Doherty
2026-07-15 16:28:11 -04:00
parent 824facab39
commit d78b533045
8 changed files with 238 additions and 9 deletions
@@ -1,9 +0,0 @@
namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>
/// Internal marker giving the assembly a compilable input while the contract
/// surface is scaffolded. Replace with real contracts as they are implemented.
/// </summary>
internal static class AssemblyMarker
{
}
@@ -0,0 +1,20 @@
namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>
/// Describes how the plaintext of a secret should be interpreted by consumers. This
/// is metadata only — the stored ciphertext is opaque bytes regardless of content type.
/// </summary>
public enum SecretContentType
{
/// <summary>Arbitrary UTF-8 text (the default).</summary>
Text,
/// <summary>A database or service connection string.</summary>
ConnectionString,
/// <summary>A JSON document.</summary>
Json,
/// <summary>Binary data carried as a Base64-encoded string.</summary>
BinaryBase64,
}
@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>
/// A compact per-secret manifest row used for cluster anti-entropy: it carries just
/// enough state (name, revision, tombstone flag, timestamp) for two nodes to compare
/// inventories and reconcile which secrets are newer without exchanging ciphertext.
/// </summary>
public record SecretManifestEntry
{
/// <summary>The normalized secret name.</summary>
public required SecretName Name { get; init; }
/// <summary>Monotonic revision number used to determine which side is newer.</summary>
public required long Revision { get; init; }
/// <summary>When the secret was last updated (UTC).</summary>
public required DateTimeOffset UpdatedUtc { get; init; }
/// <summary>Whether the secret is soft-deleted (tombstoned).</summary>
public bool IsDeleted { get; init; }
}
@@ -0,0 +1,39 @@
namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>
/// The safe, UI-facing projection of a secret. Deliberately carries <b>no</b>
/// <see cref="byte"/> array members, so neither ciphertext nor plaintext can leak into
/// rendered markup or logs via this type — it is safe to bind directly in management UIs.
/// </summary>
public record SecretMetadata
{
/// <summary>The normalized secret name.</summary>
public required SecretName Name { get; init; }
/// <summary>Optional human-readable description.</summary>
public string? Description { get; init; }
/// <summary>How the decrypted plaintext should be interpreted.</summary>
public required SecretContentType ContentType { get; init; }
/// <summary>Identifier of the key-encryption key (KEK) that wrapped the DEK.</summary>
public required string KekId { get; init; }
/// <summary>Monotonic revision number.</summary>
public required long Revision { get; init; }
/// <summary>Whether the secret is soft-deleted (tombstoned).</summary>
public bool IsDeleted { get; init; }
/// <summary>When the secret was first created (UTC).</summary>
public required DateTimeOffset CreatedUtc { get; init; }
/// <summary>When the secret was last updated (UTC).</summary>
public required DateTimeOffset UpdatedUtc { get; init; }
/// <summary>Principal that created the secret, if known.</summary>
public string? CreatedBy { get; init; }
/// <summary>Principal that last updated the secret, if known.</summary>
public string? UpdatedBy { get; init; }
}
@@ -0,0 +1,63 @@
namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>
/// A validated, normalized secret name. Names are trimmed, lowercased (invariant
/// culture), and constrained to a filesystem-safe allow-list so they can double as
/// path segments and stable identifiers. Normalization happens once, at construction.
/// </summary>
public readonly record struct SecretName
{
private const string AllowedChars = "abcdefghijklmnopqrstuvwxyz0123456789._/-";
/// <summary>
/// Creates a <see cref="SecretName"/> from the given raw value, normalizing then
/// validating it.
/// </summary>
/// <param name="value">The raw name to normalize and validate.</param>
/// <exception cref="ArgumentException">
/// The value is null, empty, or whitespace; contains the path-traversal sequence
/// <c>..</c>; or contains any character outside <c>[a-z0-9._/-]</c> (after trim and
/// lowercasing).
/// </exception>
public SecretName(string value)
{
string normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(normalized))
{
throw new ArgumentException(
$"Secret name must not be null, empty, or whitespace (was '{value}').",
nameof(value));
}
if (normalized.Contains("..", StringComparison.Ordinal))
{
throw new ArgumentException(
$"Secret name must not contain the path-traversal sequence '..' (was '{value}').",
nameof(value));
}
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;
}
/// <summary>The normalized secret name (trimmed, lowercased, allow-list validated).</summary>
public string Value { get; }
/// <summary>Returns the normalized <see cref="Value"/>.</summary>
/// <returns>The normalized secret name.</returns>
public override string ToString() => Value;
/// <summary>Implicitly converts a <see cref="SecretName"/> to its underlying string value.</summary>
/// <param name="name">The secret name to convert.</param>
public static implicit operator string(SecretName name) => name.Value;
}
@@ -0,0 +1,13 @@
namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>Thrown when the master key (KEK) required to wrap or unwrap a DEK is unavailable.</summary>
public sealed class MasterKeyUnavailableException(string message) : InvalidOperationException(message);
/// <summary>Thrown when a secret's ciphertext cannot be decrypted or its authentication tag fails to verify.</summary>
public sealed class SecretDecryptionException(string message) : InvalidOperationException(message);
/// <summary>Thrown when a requested secret does not exist (or is tombstoned) in the store.</summary>
public sealed class SecretNotFoundException(string message) : InvalidOperationException(message);
/// <summary>Thrown when the secret store cannot be migrated to the supported schema.</summary>
public sealed class SecretStoreMigrationException(string message) : InvalidOperationException(message);
@@ -0,0 +1,61 @@
namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>
/// The encrypted, at-rest representation of a secret — one database row. This type
/// <b>never</b> holds plaintext: the secret material lives only in <see cref="Ciphertext"/>,
/// sealed under AES-256-GCM with a per-secret data-encryption key (DEK) that is itself
/// wrapped by the key-encryption key (KEK) identified by <see cref="KekId"/>.
/// </summary>
public record StoredSecret
{
/// <summary>The normalized secret name (primary identity).</summary>
public required SecretName Name { get; init; }
/// <summary>Optional human-readable description shown in management surfaces.</summary>
public string? Description { get; init; }
/// <summary>How the decrypted plaintext should be interpreted.</summary>
public required SecretContentType ContentType { get; init; }
/// <summary>The AES-256-GCM ciphertext of the secret plaintext.</summary>
public required byte[] Ciphertext { get; init; }
/// <summary>The AES-256-GCM nonce used to seal <see cref="Ciphertext"/>.</summary>
public required byte[] Nonce { get; init; }
/// <summary>The AES-256-GCM authentication tag for <see cref="Ciphertext"/>.</summary>
public required byte[] Tag { get; init; }
/// <summary>The per-secret data-encryption key (DEK), wrapped by the KEK.</summary>
public required byte[] WrappedDek { get; init; }
/// <summary>The AES-256-GCM nonce used to wrap <see cref="WrappedDek"/>.</summary>
public required byte[] WrapNonce { get; init; }
/// <summary>The AES-256-GCM authentication tag for <see cref="WrappedDek"/>.</summary>
public required byte[] WrapTag { get; init; }
/// <summary>Identifier of the key-encryption key (KEK) that wrapped the DEK.</summary>
public required string KekId { get; init; }
/// <summary>Monotonic revision number, incremented on each update.</summary>
public required long Revision { get; init; }
/// <summary>Whether the secret is soft-deleted (tombstoned).</summary>
public bool IsDeleted { get; init; }
/// <summary>When the secret was soft-deleted, if it is deleted; otherwise <c>null</c>.</summary>
public DateTimeOffset? DeletedUtc { get; init; }
/// <summary>When the secret was first created (UTC).</summary>
public required DateTimeOffset CreatedUtc { get; init; }
/// <summary>When the secret was last updated (UTC).</summary>
public required DateTimeOffset UpdatedUtc { get; init; }
/// <summary>Principal that created the secret, if known.</summary>
public string? CreatedBy { get; init; }
/// <summary>Principal that last updated the secret, if known.</summary>
public string? UpdatedBy { get; init; }
}
@@ -0,0 +1,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<ArgumentException>(() => new SecretName(input));
}