Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Crypto/MalformedRowTests.cs
T
Joseph Doherty b64628de0b fix(secrets): malformed rows fail as SecretDecryptionException; one KEK snapshot per cipher op (scadaproj#45)
Secrets-004: AesGcmEnvelopeCipher.Decrypt now wraps the ArgumentException AesGcm throws for a
wrong-length nonce/tag/wrapped DEK as SecretDecryptionException (as Rewrap already did), so the
resolver's decryption-failed audit fires.

Secrets-007: new MasterKeySnapshot (key + KekId from one reading, zeroed on Dispose) and a
default-implemented IMasterKeyProvider.GetSnapshot(); MasterKeyProviderBase overrides it to
resolve the source once and derive the id from that same key. Encrypt/Decrypt/Rewrap take one
snapshot per KEK and dispose it, so a key file swapped mid-operation can no longer stamp a row
with one key's id and wrap it under another. Intermediate key buffers are zeroed. On-disk format
unchanged: a golden row written by the 0.6.5 cipher still decrypts.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:32:28 -05:00

65 lines
2.8 KiB
C#

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.Crypto;
/// <summary>
/// Secrets-004: a row whose crypto fields have the wrong length (truncated by a buggy replication
/// peer or a hand-edited database) must fail as <see cref="SecretDecryptionException"/> — the only
/// failure <see cref="ISecretCipher.Decrypt"/> documents — never as a raw <see cref="ArgumentException"/>.
/// </summary>
public class MalformedRowTests
{
public static TheoryData<string> Fields => new() { "Nonce", "Tag", "WrappedDek", "WrapNonce", "WrapTag" };
private static StoredSecret Truncate(StoredSecret row, string field) => field switch
{
"Nonce" => row with { Nonce = new byte[5] },
"Tag" => row with { Tag = new byte[3] },
"WrappedDek" => row with { WrappedDek = row.WrappedDek[..31] },
"WrapNonce" => row with { WrapNonce = new byte[5] },
"WrapTag" => row with { WrapTag = new byte[3] },
_ => throw new ArgumentOutOfRangeException(nameof(field)),
};
[Theory]
[MemberData(nameof(Fields))]
public void A_wrong_length_field_fails_as_a_decryption_error(string field)
{
var cipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("k1"));
StoredSecret row = cipher.Encrypt(new SecretName("sql/foo"), "hunter2", SecretContentType.Text);
Assert.Equal("hunter2", cipher.Decrypt(row)); // positive control: the untouched row decrypts
StoredSecret corrupt = Truncate(row, field);
var ex = Assert.Throws<SecretDecryptionException>(() => cipher.Decrypt(corrupt));
Assert.IsAssignableFrom<ArgumentException>(ex.InnerException);
Assert.DoesNotContain("hunter2", ex.Message);
}
[Fact]
public async Task The_resolver_audits_a_malformed_row_as_decryption_failed()
{
var cipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("k1"));
var store = new CountingSecretStore();
var audit = new CapturingAuditWriter();
var resolver = new DefaultSecretResolver(store, cipher, audit, TimeSpan.FromSeconds(30));
var name = new SecretName("sql/foo");
StoredSecret row = cipher.Encrypt(name, "hunter2", SecretContentType.Text) with
{
Revision = 1,
CreatedUtc = DateTimeOffset.UnixEpoch,
UpdatedUtc = DateTimeOffset.UnixEpoch,
};
store.Seed(row with { WrappedDek = row.WrappedDek[..31] });
await Assert.ThrowsAsync<SecretDecryptionException>(() => resolver.GetAsync(name, CancellationToken.None));
AuditEvent evt = Assert.Single(audit.Events);
Assert.Equal("secret.resolve.decryption-failed", evt.Action);
Assert.Equal(AuditOutcome.Failure, evt.Outcome);
}
}