fix(secrets): audit decryption failures in resolver (fail-loud, no plaintext)
This commit is contained in:
@@ -118,6 +118,37 @@ public class DefaultSecretResolverTests
|
||||
Assert.Equal(AuditOutcome.Failure, evt.Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_DecryptionFailure_AuditsFailure_AndRethrows()
|
||||
{
|
||||
// The resolver's cipher and the encrypting cipher share a KekId ("k1") but hold different
|
||||
// random key bytes, so the DEK unwrap fails closed with a SecretDecryptionException.
|
||||
var store = new CountingSecretStore();
|
||||
var audit = new CapturingAuditWriter();
|
||||
var clock = new MutableTimeProvider(DateTimeOffset.UnixEpoch);
|
||||
var resolverCipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("k1"));
|
||||
var resolver = new DefaultSecretResolver(store, resolverCipher, audit, Ttl, actorAccessor: null, clock);
|
||||
|
||||
var foreignCipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("k1")); // same id, different key
|
||||
var name = new SecretName("sql/foo");
|
||||
store.Seed(MakeRow(foreignCipher, name, "hunter2"));
|
||||
|
||||
// Fail-loud: the integrity failure must propagate, not be masked as a benign miss/null.
|
||||
await Assert.ThrowsAsync<SecretDecryptionException>(() => resolver.GetAsync(name, CancellationToken.None));
|
||||
|
||||
AuditEvent evt = Assert.Single(audit.Events);
|
||||
Assert.Equal(AuditOutcome.Failure, evt.Outcome);
|
||||
Assert.Equal("secret.resolve.decryption-failed", evt.Action);
|
||||
Assert.Equal("sql/foo", evt.Target);
|
||||
|
||||
// Hard guarantee: the plaintext must NEVER appear in any serialized audit field.
|
||||
foreach (AuditEvent captured in audit.Events)
|
||||
{
|
||||
string json = JsonSerializer.Serialize(captured);
|
||||
Assert.DoesNotContain("hunter2", json, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalidate_RemovesCacheEntry()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user