b64628de0b
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
65 lines
2.8 KiB
C#
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);
|
|
}
|
|
}
|