feat(secrets): abstractions value types, enums, exceptions
This commit is contained in:
@@ -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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user