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
This commit is contained in:
@@ -21,4 +21,18 @@ public interface IMasterKeyProvider
|
||||
/// to the KEK that wrapped their DEK.
|
||||
/// </summary>
|
||||
string KekId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns the key and its <see cref="KekId"/> as one consistent, caller-owned snapshot. The
|
||||
/// cipher takes exactly one snapshot per operation and disposes it (zeroing its key copy).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default implementation reads <see cref="KekId"/> and then copies
|
||||
/// <see cref="GetMasterKey"/>; a provider whose source can change between those two reads (a
|
||||
/// file or environment variable) should override it to resolve the key once and derive the id
|
||||
/// from that same reading, as <c>MasterKeyProviderBase</c> does.
|
||||
/// </remarks>
|
||||
/// <returns>A snapshot the caller must dispose.</returns>
|
||||
/// <exception cref="MasterKeyUnavailableException">The master key cannot be resolved.</exception>
|
||||
MasterKeySnapshot GetSnapshot() => new(KekId, GetMasterKey().ToArray());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// One consistent reading of a master key (KEK): the 32 key bytes together with the
|
||||
/// <see cref="IMasterKeyProvider.KekId"/> that identifies <i>those</i> bytes. Obtained from
|
||||
/// <see cref="IMasterKeyProvider.GetSnapshot"/> once per cipher operation, so the id stamped on a
|
||||
/// row and the key that wraps it can never come from two different reads of a source that changed
|
||||
/// in between (a key file replaced mid-operation).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The snapshot owns its key buffer: <see cref="Dispose"/> zeroes it with
|
||||
/// <see cref="CryptographicOperations.ZeroMemory"/>. Always dispose a snapshot
|
||||
/// (<c>using var snapshot = provider.GetSnapshot();</c>) and never retain <see cref="Key"/> past it.
|
||||
/// </remarks>
|
||||
public sealed class MasterKeySnapshot : IDisposable
|
||||
{
|
||||
private readonly byte[] _key;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a snapshot that takes ownership of <paramref name="key"/> (it is zeroed on
|
||||
/// <see cref="Dispose"/>; pass a buffer nothing else uses).
|
||||
/// </summary>
|
||||
/// <param name="kekId">The non-secret id of <paramref name="key"/>.</param>
|
||||
/// <param name="key">The key bytes; ownership transfers to the snapshot.</param>
|
||||
public MasterKeySnapshot(string kekId, byte[] key)
|
||||
{
|
||||
KekId = kekId ?? throw new ArgumentNullException(nameof(kekId));
|
||||
_key = key ?? throw new ArgumentNullException(nameof(key));
|
||||
}
|
||||
|
||||
/// <summary>The non-secret identifier of <see cref="Key"/>.</summary>
|
||||
public string KekId { get; }
|
||||
|
||||
/// <summary>The key bytes. Valid only until <see cref="Dispose"/>, which zeroes them.</summary>
|
||||
public ReadOnlyMemory<byte> Key => _key;
|
||||
|
||||
/// <summary>Zeroes the key buffer.</summary>
|
||||
public void Dispose() => CryptographicOperations.ZeroMemory(_key);
|
||||
}
|
||||
@@ -22,8 +22,15 @@ namespace ZB.MOM.WW.Secrets.Crypto;
|
||||
/// constant-time comparison is required. Every cryptographic failure path — a mismatched tag,
|
||||
/// a wrong or unknown KEK, or any other <see cref="CryptographicException"/> — is surfaced as a
|
||||
/// <see cref="SecretDecryptionException"/>; the cipher never returns a wrong plaintext and never
|
||||
/// leaks a raw cryptographic exception. The transient DEK buffer is always zeroed with
|
||||
/// <see cref="CryptographicOperations.ZeroMemory"/>.
|
||||
/// leaks a raw cryptographic exception. A malformed row (a wrong-length nonce, tag or wrapped DEK,
|
||||
/// which <see cref="AesGcm"/> reports as <see cref="ArgumentException"/>) fails the same way. The
|
||||
/// transient DEK buffer is always zeroed with <see cref="CryptographicOperations.ZeroMemory"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every operation takes exactly one <see cref="MasterKeySnapshot"/> per KEK
|
||||
/// (<see cref="IMasterKeyProvider.GetSnapshot"/>), so the KEK id stamped on or checked against a row
|
||||
/// and the key used for it always come from the same reading, and the snapshot's key copy is zeroed
|
||||
/// when the operation ends.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class AesGcmEnvelopeCipher : ISecretCipher
|
||||
@@ -49,8 +56,9 @@ public sealed class AesGcmEnvelopeCipher : ISecretCipher
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plaintext);
|
||||
|
||||
string kekId = _masterKeyProvider.KekId;
|
||||
ReadOnlySpan<byte> masterKey = _masterKeyProvider.GetMasterKey().Span;
|
||||
using MasterKeySnapshot kek = _masterKeyProvider.GetSnapshot();
|
||||
string kekId = kek.KekId;
|
||||
ReadOnlySpan<byte> masterKey = kek.Key.Span;
|
||||
|
||||
byte[] dek = new byte[KeySizeBytes];
|
||||
try
|
||||
@@ -110,13 +118,13 @@ public sealed class AesGcmEnvelopeCipher : ISecretCipher
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(secret);
|
||||
|
||||
string kekId = _masterKeyProvider.KekId;
|
||||
if (!string.Equals(kekId, secret.KekId, StringComparison.Ordinal))
|
||||
using MasterKeySnapshot kek = _masterKeyProvider.GetSnapshot();
|
||||
if (!string.Equals(kek.KekId, secret.KekId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new SecretDecryptionException($"Row wrapped by unknown KEK '{secret.KekId}'.");
|
||||
}
|
||||
|
||||
ReadOnlySpan<byte> masterKey = _masterKeyProvider.GetMasterKey().Span;
|
||||
ReadOnlySpan<byte> masterKey = kek.Key.Span;
|
||||
|
||||
byte[] dek = new byte[KeySizeBytes];
|
||||
try
|
||||
@@ -148,6 +156,15 @@ public sealed class AesGcmEnvelopeCipher : ISecretCipher
|
||||
throw new SecretDecryptionException(
|
||||
$"Failed to decrypt secret '{secret.Name.Value}'.", ex);
|
||||
}
|
||||
// AesGcm throws ArgumentException for a wrong-length nonce or tag, or a ciphertext/plaintext
|
||||
// length mismatch (a WrappedDek that is not 32 bytes): a corrupt or truncated row. It is a
|
||||
// decryption failure like any other, so callers (the resolver's decryption-failed audit)
|
||||
// classify it correctly (Secrets-004). Mirrors Rewrap.
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
throw new SecretDecryptionException(
|
||||
$"Failed to decrypt secret '{secret.Name.Value}': the row is malformed.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(dek);
|
||||
@@ -161,18 +178,21 @@ public sealed class AesGcmEnvelopeCipher : ISecretCipher
|
||||
ArgumentNullException.ThrowIfNull(oldKek);
|
||||
ArgumentNullException.ThrowIfNull(newKek);
|
||||
|
||||
using MasterKeySnapshot oldSnapshot = oldKek.GetSnapshot();
|
||||
|
||||
// Fail closed if the row is not actually wrapped by the supplied old KEK — a caller must
|
||||
// not silently "rewrap" a row that is already on a different key.
|
||||
if (!string.Equals(secret.KekId, oldKek.KekId, StringComparison.Ordinal))
|
||||
if (!string.Equals(secret.KekId, oldSnapshot.KekId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new SecretDecryptionException(
|
||||
$"Cannot rewrap secret '{secret.Name.Value}': row is wrapped by KEK '{secret.KekId}', " +
|
||||
$"not the supplied old KEK '{oldKek.KekId}'.");
|
||||
$"not the supplied old KEK '{oldSnapshot.KekId}'.");
|
||||
}
|
||||
|
||||
ReadOnlySpan<byte> oldMasterKey = oldKek.GetMasterKey().Span;
|
||||
ReadOnlySpan<byte> newMasterKey = newKek.GetMasterKey().Span;
|
||||
string newKekId = newKek.KekId;
|
||||
using MasterKeySnapshot newSnapshot = newKek.GetSnapshot();
|
||||
ReadOnlySpan<byte> oldMasterKey = oldSnapshot.Key.Span;
|
||||
ReadOnlySpan<byte> newMasterKey = newSnapshot.Key.Span;
|
||||
string newKekId = newSnapshot.KekId;
|
||||
|
||||
byte[] dek = new byte[KeySizeBytes];
|
||||
try
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
@@ -45,6 +46,7 @@ public sealed class FileMasterKeyProvider : MasterKeyProviderBase
|
||||
|
||||
// Otherwise treat the content as base64 text.
|
||||
string text = Encoding.UTF8.GetString(raw).Trim();
|
||||
CryptographicOperations.ZeroMemory(raw);
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(text);
|
||||
|
||||
@@ -13,6 +13,11 @@ namespace ZB.MOM.WW.Secrets.MasterKey;
|
||||
/// file value takes effect without recreating the provider. <see cref="KekId"/> is likewise derived
|
||||
/// from the currently resolved key (unless an explicit id is configured), so it stays stable for a
|
||||
/// given key and shifts only when the key itself changes.
|
||||
/// <para>
|
||||
/// <see cref="GetSnapshot"/> resolves the source <b>once</b> and derives the id from that same
|
||||
/// reading, so a key file replaced mid-operation can never produce a row stamped with one key's id
|
||||
/// and wrapped under another (Secrets-007). Intermediate key buffers are zeroed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class MasterKeyProviderBase : IMasterKeyProvider
|
||||
{
|
||||
@@ -45,7 +50,32 @@ public abstract class MasterKeyProviderBase : IMasterKeyProvider
|
||||
return explicitId;
|
||||
}
|
||||
|
||||
return DeriveKekId(ResolveValidatedKey());
|
||||
byte[] key = ResolveValidatedKey();
|
||||
try
|
||||
{
|
||||
return DeriveKekId(key);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public MasterKeySnapshot GetSnapshot()
|
||||
{
|
||||
byte[] key = ResolveValidatedKey();
|
||||
try
|
||||
{
|
||||
string? explicitId = _options.KekId;
|
||||
string kekId = string.IsNullOrEmpty(explicitId) ? DeriveKekId(key) : explicitId;
|
||||
return new MasterKeySnapshot(kekId, key);
|
||||
}
|
||||
catch
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(key);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +84,10 @@ public abstract class MasterKeyProviderBase : IMasterKeyProvider
|
||||
/// <see cref="MasterKeyUnavailableException"/> when the source is missing or malformed; length
|
||||
/// validation is applied by the base class.
|
||||
/// </summary>
|
||||
/// <returns>The resolved key bytes (any length; validated by the caller).</returns>
|
||||
/// <returns>
|
||||
/// The resolved key bytes (any length; validated by the caller), in a <b>freshly allocated</b>
|
||||
/// buffer the caller owns: the base class zeroes it after use, so never return a cached array.
|
||||
/// </returns>
|
||||
protected abstract byte[] ResolveKeyBytes();
|
||||
|
||||
private byte[] ResolveValidatedKey()
|
||||
@@ -62,6 +95,7 @@ public abstract class MasterKeyProviderBase : IMasterKeyProvider
|
||||
byte[] key = ResolveKeyBytes();
|
||||
if (key.Length != KeySizeBytes)
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(key);
|
||||
throw new MasterKeyUnavailableException(
|
||||
$"Master key must be exactly {KeySizeBytes} bytes (AES-256) but was {key.Length} bytes.");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.Security.Cryptography;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Crypto;
|
||||
using ZB.MOM.WW.Secrets.MasterKey;
|
||||
using ZB.MOM.WW.Secrets.Tests.Fakes;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Tests.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// Secrets-007: the cipher must read the KEK id and the key from ONE resolution of the source, and
|
||||
/// zero the key copy it used. Also pins the on-disk format: a row written by the pre-snapshot cipher
|
||||
/// (0.6.5) must still decrypt.
|
||||
/// </summary>
|
||||
public class MasterKeySnapshotTests
|
||||
{
|
||||
private static readonly byte[] KeyA = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
|
||||
private static readonly byte[] KeyB = Enumerable.Range(100, 32).Select(i => (byte)i).ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// A file-like provider whose source is replaced after its first read: the first resolution
|
||||
/// returns key A, every later one key B (an operator swapping the key file mid-operation).
|
||||
/// </summary>
|
||||
private sealed class SwappedOnceProvider : MasterKeyProviderBase
|
||||
{
|
||||
private int _reads;
|
||||
|
||||
public SwappedOnceProvider() : base(new MasterKeyOptions()) { }
|
||||
|
||||
public int Reads => _reads;
|
||||
|
||||
protected override byte[] ResolveKeyBytes() =>
|
||||
(byte[])(Interlocked.Increment(ref _reads) == 1 ? KeyA : KeyB).Clone();
|
||||
}
|
||||
|
||||
private sealed class FixedKeyProvider : MasterKeyProviderBase
|
||||
{
|
||||
private readonly byte[] _key;
|
||||
|
||||
public FixedKeyProvider(byte[] key) : base(new MasterKeyOptions()) => _key = key;
|
||||
|
||||
protected override byte[] ResolveKeyBytes() => (byte[])_key.Clone();
|
||||
}
|
||||
|
||||
/// <summary>Records the key buffer of every snapshot it hands out.</summary>
|
||||
private sealed class RecordingProvider(byte[] key, string kekId) : IMasterKeyProvider
|
||||
{
|
||||
public List<byte[]> Handed { get; } = new();
|
||||
|
||||
public string KekId => kekId;
|
||||
|
||||
public ReadOnlyMemory<byte> GetMasterKey() => (byte[])key.Clone();
|
||||
|
||||
public MasterKeySnapshot GetSnapshot()
|
||||
{
|
||||
byte[] copy = (byte[])key.Clone();
|
||||
Handed.Add(copy);
|
||||
return new MasterKeySnapshot(kekId, copy);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encrypt_stamps_the_id_of_the_key_that_actually_wrapped_the_row()
|
||||
{
|
||||
var swapping = new SwappedOnceProvider();
|
||||
var cipher = new AesGcmEnvelopeCipher(swapping);
|
||||
|
||||
StoredSecret row = cipher.Encrypt(new SecretName("sql/foo"), "hunter2", SecretContentType.Text);
|
||||
|
||||
// One resolution per operation, not one for the id and another for the key.
|
||||
Assert.Equal(1, swapping.Reads);
|
||||
|
||||
// The row claims key A and is wrapped by key A, so a node holding key A reads it.
|
||||
var holderOfA = new AesGcmEnvelopeCipher(new FixedKeyProvider(KeyA));
|
||||
Assert.Equal(new FixedKeyProvider(KeyA).KekId, row.KekId);
|
||||
Assert.Equal("hunter2", holderOfA.Decrypt(row));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decrypt_and_rewrap_resolve_each_kek_once()
|
||||
{
|
||||
var aCipher = new AesGcmEnvelopeCipher(new FixedKeyProvider(KeyA));
|
||||
StoredSecret row = aCipher.Encrypt(new SecretName("sql/foo"), "hunter2", SecretContentType.Text);
|
||||
|
||||
var swapping = new SwappedOnceProvider();
|
||||
Assert.Equal("hunter2", new AesGcmEnvelopeCipher(swapping).Decrypt(row));
|
||||
Assert.Equal(1, swapping.Reads);
|
||||
|
||||
var oldKek = new SwappedOnceProvider();
|
||||
var newKek = new FixedKeyProvider(KeyB);
|
||||
StoredSecret rewrapped = aCipher.Rewrap(row, oldKek, newKek);
|
||||
Assert.Equal(1, oldKek.Reads);
|
||||
Assert.Equal("hunter2", new AesGcmEnvelopeCipher(new FixedKeyProvider(KeyB)).Decrypt(rewrapped));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_cipher_zeroes_every_key_snapshot_it_used()
|
||||
{
|
||||
var provider = new RecordingProvider(KeyA, "k1");
|
||||
var cipher = new AesGcmEnvelopeCipher(provider);
|
||||
|
||||
StoredSecret row = cipher.Encrypt(new SecretName("sql/foo"), "hunter2", SecretContentType.Text);
|
||||
Assert.Equal("hunter2", cipher.Decrypt(row));
|
||||
cipher.Rewrap(row, provider, new RecordingProvider(KeyB, "k2"));
|
||||
|
||||
Assert.Equal(3, provider.Handed.Count);
|
||||
Assert.All(provider.Handed, buffer => Assert.All(buffer, b => Assert.Equal(0, b)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_default_snapshot_copies_so_disposing_it_never_zeroes_the_providers_own_key()
|
||||
{
|
||||
byte[] key = (byte[])KeyA.Clone();
|
||||
var provider = new FakeMasterKeyProvider("k1", key);
|
||||
var cipher = new AesGcmEnvelopeCipher(provider);
|
||||
|
||||
StoredSecret row = cipher.Encrypt(new SecretName("sql/foo"), "hunter2", SecretContentType.Text);
|
||||
|
||||
// The provider (which hands out its cached array) still works after the cipher zeroed its snapshot.
|
||||
Assert.Equal("hunter2", cipher.Decrypt(row));
|
||||
Assert.Equal(KeyA, provider.GetMasterKey().ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_row_written_by_the_0_6_5_cipher_still_decrypts()
|
||||
{
|
||||
// Generated with the 0.6.5 AesGcmEnvelopeCipher + FileMasterKeyProvider over a raw key file
|
||||
// holding bytes 0..31 (derived KEK id). The format is unchanged by the snapshot work.
|
||||
string keyPath = Path.Combine(Path.GetTempPath(), $"zb-secrets-golden-{Guid.NewGuid():N}.key");
|
||||
File.WriteAllBytes(keyPath, KeyA);
|
||||
try
|
||||
{
|
||||
var provider = new FileMasterKeyProvider(new MasterKeyOptions
|
||||
{
|
||||
Source = MasterKeySource.File,
|
||||
FilePath = keyPath,
|
||||
});
|
||||
var row = new StoredSecret
|
||||
{
|
||||
Name = new SecretName("sql/golden"),
|
||||
ContentType = SecretContentType.Text,
|
||||
Ciphertext = Convert.FromHexString("65D1B5D4FD8F538D9B58A622E202D84CDACD1F17D809DEBA0E1575DB"),
|
||||
Nonce = Convert.FromHexString("023EF785E6D7318EB0F1A238"),
|
||||
Tag = Convert.FromHexString("AAFF7A1E634C74332D4D4B75B36DE293"),
|
||||
WrappedDek = Convert.FromHexString("8BA7B8BD1A289CF97AE91386B72061C97A7CD7FF9487140590015A0ED46E79B2"),
|
||||
WrapNonce = Convert.FromHexString("04B946E3103E0E523714DDA8"),
|
||||
WrapTag = Convert.FromHexString("F542CA1BF350577B400E524B816D64A3"),
|
||||
KekId = "sha256:630dcd2966c4",
|
||||
Revision = 1,
|
||||
CreatedUtc = DateTimeOffset.UnixEpoch,
|
||||
UpdatedUtc = DateTimeOffset.UnixEpoch,
|
||||
};
|
||||
|
||||
Assert.Equal("sha256:630dcd2966c4", provider.KekId);
|
||||
Assert.Equal("correct horse battery staple", new AesGcmEnvelopeCipher(provider).Decrypt(row));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(keyPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disposing_a_snapshot_zeroes_its_key()
|
||||
{
|
||||
byte[] key = RandomNumberGenerator.GetBytes(32);
|
||||
var snapshot = new MasterKeySnapshot("k1", key);
|
||||
snapshot.Dispose();
|
||||
Assert.All(key, b => Assert.Equal(0, b));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user