diff --git a/CHANGELOG.md b/CHANGELOG.md
index fe154ec..f1cf597 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Fixed
+
+- **A Windows feature update no longer invalidates the local keystore.** The
+ file backend's key-encryption key was derived from
+ `{MachineName}:{UserName}:{OSVersion}`, so a feature update (e.g. 25H2 → 26H2)
+ changed `Environment.OSVersion`, rotated the KEK, and left every credential
+ undecryptable with `AuthenticationTagMismatchException` and no migration path.
+ The KEK now derives from stable machine/user identity only — `OSVersion` is
+ dropped — so later OS updates cannot break the store. Machine and user name
+ still bind the keystore to this machine/user, and the security boundary is
+ unchanged (filesystem permissions on the credentials directory).
+
+### Changed
+
+- **Keystore format bumped to version 2** (`.keystore` header). A version-1
+ keystore — and a legacy headerless one, both sealed under the old
+ OSVersion-based KEK — is read with the legacy KEK and then **transparently
+ re-sealed as version 2 on first load**, so an existing, still-readable store
+ migrates itself on upgrade and becomes immune to future OS updates. The
+ re-seal is best-effort: it overwrites in place (safe, since the data key is
+ unchanged) and a persistence failure never fails an otherwise-successful read.
+ A keystore already broken by an OS update performed *before* this upgrade
+ cannot be recovered by the library; the integrity-error message now names that
+ cause.
+
## [2.0.0] — 2026-08-28
Major release. **Three provider-facing interfaces gain a `CancellationToken`** —
diff --git a/src/NextIteration.SpectreConsole.Auth/Encryption/LocalFileCredentialEncryption.cs b/src/NextIteration.SpectreConsole.Auth/Encryption/LocalFileCredentialEncryption.cs
index 1879b93..d86bec7 100644
--- a/src/NextIteration.SpectreConsole.Auth/Encryption/LocalFileCredentialEncryption.cs
+++ b/src/NextIteration.SpectreConsole.Auth/Encryption/LocalFileCredentialEncryption.cs
@@ -14,13 +14,16 @@ namespace NextIteration.SpectreConsole.Auth.Encryption
///
/// Default security model (no caller-supplied entropy): the data
/// encryption key lives encrypted in a .keystore file inside the
- /// credentials directory. That file is encrypted with a KEK derived from
- /// {MachineName}:{UserName}:{OSVersion} via PBKDF2. Because all
- /// KEK inputs are discoverable on the machine, the real security
- /// boundary is the filesystem permissions on the credentials directory,
- /// not the cryptography. An attacker with read access to the keystore
- /// file on the same machine/user can derive the KEK and decrypt
- /// credentials.
+ /// credentials directory. That file is encrypted with a KEK derived via
+ /// PBKDF2 from stable machine/user identity
+ /// ({MachineName}:{UserName}). The OS version is deliberately
+ /// not an input: it once was, but a Windows feature update then
+ /// rotated the KEK and left the keystore unreadable, so it was removed
+ /// (see the format/migration note below). Because all KEK inputs are
+ /// discoverable on the machine, the real security boundary is the
+ /// filesystem permissions on the credentials directory, not the
+ /// cryptography. An attacker with read access to the keystore file on the
+ /// same machine/user can derive the KEK and decrypt credentials.
///
///
/// Hardened mode: supply additionalEntropy via the
@@ -37,9 +40,11 @@ namespace NextIteration.SpectreConsole.Auth.Encryption
///
///
/// The .keystore file written by this version carries a format
- /// header so future changes can be detected and rejected cleanly. Legacy
- /// headerless keystores are still read; keystores written by this version
- /// are not readable by pre-header library versions.
+ /// header (magic + one-byte version). A version-1 keystore — sealed under
+ /// the old OSVersion-based KEK — and a legacy headerless keystore are both
+ /// still read, with the old KEK, and then transparently re-sealed as the
+ /// current version so a later OS update cannot break them. A keystore
+ /// written by this version is not readable by pre-header library versions.
///
///
/// Implements : disposing zeroes the in-memory
@@ -55,18 +60,26 @@ public class LocalFileCredentialEncryption : ICredentialEncryption, IDisposable
private const int TagSize = 16;
private const int KeySize = 32; // AES-256
- // Keystore file format header. A keystore written by this version is
- // prefixed with this magic and a one-byte format version, so a future
- // KDF/format change can be detected and rejected with a clear
- // "unsupported format" error instead of surfacing as an opaque
- // integrity-check failure. Keystores written by earlier versions have
- // no header (they begin with a random 12-byte nonce); those are still
- // read, since the 8-byte magic can't plausibly collide with a random
- // nonce prefix (~2^-64). Note the reverse is not true: a keystore
- // written by this version is not readable by pre-header library
- // versions.
+ // Keystore file format header. A keystore is prefixed with this magic
+ // and a one-byte format version. Keystores written by pre-header
+ // library versions have no header (they begin with a random 12-byte
+ // nonce); those are still read, since the 8-byte magic can't plausibly
+ // collide with a random nonce prefix (~2^-64). A keystore written by
+ // this version is not readable by pre-header library versions.
+ //
+ // Version 1 sealed the data key under a KEK that included
+ // Environment.OSVersion; a Windows feature update changed OSVersion and
+ // left the keystore undecryptable with no migration path. Version 2
+ // drops OSVersion from the KEK (see DeriveKeyEncryptionKey). A version-1
+ // keystore — and a legacy headerless one, which used the same
+ // OSVersion-based KEK — is read with the legacy KEK and then
+ // transparently re-sealed as version 2 on first load, so an existing
+ // store survives the upgrade and is immune to later OS updates. A
+ // version this build does not know is rejected with a clear error
+ // rather than surfacing as an opaque integrity-check failure.
private static readonly byte[] KeystoreMagic = "NISCA-KS"u8.ToArray();
- private const byte KeystoreFormatVersion = 1;
+ private const byte LegacyOsVersionFormatVersion = 1;
+ private const byte KeystoreFormatVersion = 2;
// PBKDF2-HMAC-SHA256 iteration count. OWASP 2023 guidance is
// 600,000. In default mode (no caller entropy) iterations provide
@@ -77,6 +90,12 @@ public class LocalFileCredentialEncryption : ICredentialEncryption, IDisposable
// attempt against the caller-supplied secret.
private const int Pbkdf2Iterations = 600_000;
+ // Stable, non-secret domain tag folded into the version-2 KEK password.
+ // It marks the OSVersion-free derivation (so a future KDF change can
+ // pick a new tag) and keeps the PBKDF2 password from being byte-for-byte
+ // the salt, which is the same machine/user string.
+ private const string KekDomainV2 = "keystore/kek/v2";
+
private readonly string _keyFile;
private readonly byte[] _salt;
private readonly byte[]? _callerEntropy;
@@ -196,7 +215,7 @@ public async Task DecryptAsync(string encryptedText)
// Message adapts to whether caller entropy is in play, so a
// consumer who just changed their entropy knows where to look.
var message = _callerEntropy is null
- ? "Credential data failed integrity check. The file has been tampered with, or was encrypted with a different key (for example, the keystore was copied from another machine or user)."
+ ? "Credential data failed integrity check. The file has been tampered with, or was encrypted with a different key (for example, the keystore was copied from another machine or user, or a Windows feature update changed the machine identity before this version — which no longer folds the OS version into the key — was installed)."
: "Credential data failed integrity check. The file has been tampered with, or was encrypted with a different additional-entropy value, or on a different machine.";
throw new InvalidOperationException(message, ex);
}
@@ -232,59 +251,67 @@ private async Task LoadOrCreateDataKeyAsync()
}
var stored = await File.ReadAllBytesAsync(_keyFile).ConfigureAwait(false);
- var encryptedKey = StripKeystoreHeader(stored);
- var kek = DeriveKeyEncryptionKey();
- return DecryptWithGcm(kek, encryptedKey);
+ var (encryptedKey, version) = ParseKeystore(stored);
+
+ // Version 2 is sealed under the OSVersion-free KEK; version 1 (and a
+ // legacy headerless keystore) under the old OSVersion-based KEK. A
+ // wrong KEK surfaces as AuthenticationTagMismatchException here,
+ // which DecryptAsync turns into the actionable integrity error.
+ var kek = version == KeystoreFormatVersion
+ ? DeriveKeyEncryptionKey()
+ : DeriveLegacyKeyEncryptionKey();
+
+ var dataKey = DecryptWithGcm(kek, encryptedKey);
+
+ if (version != KeystoreFormatVersion)
+ {
+ // Re-seal the recovered data key under the current KEK so a
+ // later OS update can't break the store. Best-effort: the
+ // decrypt has already succeeded, so a persistence failure must
+ // not fail the read.
+ await TryMigrateToCurrentFormatAsync(dataKey).ConfigureAwait(false);
+ }
+
+ return dataKey;
}
///
- /// Returns the AES-GCM payload of a keystore file, skipping the format
- /// header when present. A keystore written by this version begins with
+ /// Splits a keystore file into its AES-GCM payload and format version.
+ /// A keystore written by a header-carrying version begins with
/// followed by a one-byte version; a legacy
- /// keystore has no header and is returned unchanged. Throws when the
- /// header is present but its version is not understood, so a keystore
- /// from a newer library fails clearly rather than as an opaque
- /// integrity error.
+ /// headerless keystore has no header and is reported as
+ /// (it used the same
+ /// OSVersion-based KEK). Throws when a header is present but its version
+ /// is not understood, so a keystore from a newer library fails clearly
+ /// rather than as an opaque integrity error.
///
- private static byte[] StripKeystoreHeader(byte[] stored)
+ private static (byte[] EncryptedKey, byte Version) ParseKeystore(byte[] stored)
{
var headerLength = KeystoreMagic.Length + 1;
if (stored.Length < headerLength ||
!stored.AsSpan(0, KeystoreMagic.Length).SequenceEqual(KeystoreMagic))
{
- // No recognisable header — treat as a legacy headerless keystore.
- return stored;
+ // No recognisable header — a pre-header keystore, sealed under
+ // the legacy OSVersion-based KEK.
+ return (stored, LegacyOsVersionFormatVersion);
}
var version = stored[KeystoreMagic.Length];
- if (version != KeystoreFormatVersion)
+ if (version is not (LegacyOsVersionFormatVersion or KeystoreFormatVersion))
{
throw new InvalidOperationException(
- $"Unsupported keystore format version {version}. This build supports version {KeystoreFormatVersion}; the keystore was likely written by a newer version of the library.");
+ $"Unsupported keystore format version {version}. This build supports versions {LegacyOsVersionFormatVersion}–{KeystoreFormatVersion}; the keystore was likely written by a newer version of the library.");
}
- return stored[headerLength..];
+ return (stored[headerLength..], version);
}
private async Task CreateKeyFileAsync()
{
var key = RandomNumberGenerator.GetBytes(KeySize);
- var kek = DeriveKeyEncryptionKey();
- var encryptedKey = EncryptWithGcm(kek, key);
+ var framed = SealDataKey(key);
- // Frame the payload with the format header so the version is
- // self-describing on the next read.
- var framed = new byte[KeystoreMagic.Length + 1 + encryptedKey.Length];
- Buffer.BlockCopy(KeystoreMagic, 0, framed, 0, KeystoreMagic.Length);
- framed[KeystoreMagic.Length] = KeystoreFormatVersion;
- Buffer.BlockCopy(encryptedKey, 0, framed, KeystoreMagic.Length + 1, encryptedKey.Length);
- encryptedKey = framed;
-
- var directory = Path.GetDirectoryName(_keyFile);
- if (!string.IsNullOrEmpty(directory))
- {
- CredentialsDirectory.Ensure(directory);
- }
+ EnsureKeystoreDirectory();
// Atomic AND exclusive. Crash-safety is why this is a temp-then-rename
// (a half-written keystore would render every credential undecryptable);
@@ -298,19 +325,91 @@ private async Task CreateKeyFileAsync()
// so both processes converge on one key and nothing is lost.
_ = await AtomicFile.TryWriteNewAsync(
_keyFile,
- encryptedKey,
+ framed,
OperatingSystem.IsWindows() ? null : UnixFileMode.UserRead | UnixFileMode.UserWrite).ConfigureAwait(false);
}
- private byte[] DeriveKeyEncryptionKey()
+ ///
+ /// Re-seals an already-recovered data key under the current
+ /// (OSVersion-free) KEK, upgrading a version-1 or legacy headerless
+ /// keystore to the current format in place. Best-effort: any I/O or
+ /// permission failure is swallowed because the caller already holds a
+ /// valid in-memory data key and its read has succeeded — a persistence
+ /// failure must not turn a working decrypt into an error. The next run
+ /// retries the migration.
+ ///
+ private async Task TryMigrateToCurrentFormatAsync(byte[] dataKey)
+ {
+ try
+ {
+ var framed = SealDataKey(dataKey);
+ EnsureKeystoreDirectory();
+
+ // Overwrite is safe here, unlike the first-create path: the data
+ // key is unchanged, so a racing migrator that re-seals the same
+ // key under a fresh nonce loses nothing — only the KEK wrapping
+ // and format version change.
+ await AtomicFile.WriteAllBytesAsync(
+ _keyFile,
+ framed,
+ OperatingSystem.IsWindows() ? null : UnixFileMode.UserRead | UnixFileMode.UserWrite).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // Swallowed by design — see the summary.
+ }
+ }
+
+ ///
+ /// Encrypts under the current KEK and frames
+ /// it with the current format header, ready to write as a keystore.
+ ///
+ private byte[] SealDataKey(byte[] dataKey)
{
- var machineIdentity = $"{Environment.MachineName}:{Environment.UserName}:{Environment.OSVersion}";
+ var encryptedKey = EncryptWithGcm(DeriveKeyEncryptionKey(), dataKey);
+ // Frame the payload with the format header so the version is
+ // self-describing on the next read.
+ var framed = new byte[KeystoreMagic.Length + 1 + encryptedKey.Length];
+ Buffer.BlockCopy(KeystoreMagic, 0, framed, 0, KeystoreMagic.Length);
+ framed[KeystoreMagic.Length] = KeystoreFormatVersion;
+ Buffer.BlockCopy(encryptedKey, 0, framed, KeystoreMagic.Length + 1, encryptedKey.Length);
+ return framed;
+ }
+
+ private void EnsureKeystoreDirectory()
+ {
+ var directory = Path.GetDirectoryName(_keyFile);
+ if (!string.IsNullOrEmpty(directory))
+ {
+ CredentialsDirectory.Ensure(directory);
+ }
+ }
+
+ ///
+ /// Derives the current key-encryption key. The OS version is
+ /// deliberately excluded: it changed on every Windows feature update
+ /// (e.g. 25H2 → 26H2), which rotated the KEK and left the keystore
+ /// undecryptable. Machine and user name still bind the keystore to this
+ /// machine/user.
+ ///
+ private byte[] DeriveKeyEncryptionKey()
+ => DeriveKek($"{Environment.MachineName}:{Environment.UserName}:{KekDomainV2}");
+
+ ///
+ /// Derives the pre-version-2 key-encryption key, which folded the
+ /// volatile into the identity. Used
+ /// only to read an existing version-1 (or legacy headerless) keystore so
+ /// it can be re-sealed under .
+ ///
+ private byte[] DeriveLegacyKeyEncryptionKey()
+ => DeriveKek($"{Environment.MachineName}:{Environment.UserName}:{Environment.OSVersion}");
+
+ private byte[] DeriveKek(string machineIdentity)
+ {
if (_callerEntropy is null)
{
- // Default mode — password identical to pre-entropy behaviour
- // so keystores written by earlier library versions remain
- // readable.
+ // Default mode — the machine identity is the whole password.
return Rfc2898DeriveBytes.Pbkdf2(machineIdentity, _salt, Pbkdf2Iterations, HashAlgorithmName.SHA256, KeySize);
}
diff --git a/tests/NextIteration.SpectreConsole.Auth.Tests/Encryption/LocalFileCredentialEncryptionTests.cs b/tests/NextIteration.SpectreConsole.Auth.Tests/Encryption/LocalFileCredentialEncryptionTests.cs
index 6daef97..142545f 100644
--- a/tests/NextIteration.SpectreConsole.Auth.Tests/Encryption/LocalFileCredentialEncryptionTests.cs
+++ b/tests/NextIteration.SpectreConsole.Auth.Tests/Encryption/LocalFileCredentialEncryptionTests.cs
@@ -346,6 +346,85 @@ public async Task Entropy_DefensivelyCopied_MutationAfterConstructIsIgnored()
private static readonly byte[] KeystoreMagic = "NISCA-KS"u8.ToArray();
+ // Crypto constants mirrored from LocalFileCredentialEncryption so the
+ // tests can forge a genuine version-1 keystore.
+ private const int LegacyPbkdf2Iterations = 600_000;
+ private const int LegacyKeySize = 32;
+ private const int LegacyNonceSize = 12;
+ private const int LegacyTagSize = 16;
+
+ ///
+ /// Reads the derived data key out of an instance whose key has already
+ /// been materialised (call after a first Encrypt/Decrypt).
+ ///
+ private static async Task GetDataKeyAsync(LocalFileCredentialEncryption encryption)
+ {
+ var field = typeof(LocalFileCredentialEncryption)
+ .GetField("_dataKey", BindingFlags.NonPublic | BindingFlags.Instance)!;
+ var lazy = (Lazy>)field.GetValue(encryption)!;
+ return await lazy.Value;
+ }
+
+ ///
+ /// Writes a keystore in the pre-version-2 shape: the data key sealed
+ /// under the old OSVersion-based KEK, optionally with the version-1
+ /// header (or headerless, as the earliest builds wrote).
+ ///
+ private static async Task WriteLegacyKeystoreAsync(string directory, byte[] dataKey, bool withHeader, byte[]? entropy = null)
+ {
+ var kek = DeriveLegacyKek(entropy);
+ var wrapped = LegacyGcmEncrypt(kek, dataKey);
+
+ byte[] onDisk;
+ if (withHeader)
+ {
+ onDisk = new byte[KeystoreMagic.Length + 1 + wrapped.Length];
+ Buffer.BlockCopy(KeystoreMagic, 0, onDisk, 0, KeystoreMagic.Length);
+ onDisk[KeystoreMagic.Length] = 1; // legacy format version
+ Buffer.BlockCopy(wrapped, 0, onDisk, KeystoreMagic.Length + 1, wrapped.Length);
+ }
+ else
+ {
+ onDisk = wrapped;
+ }
+
+ await File.WriteAllBytesAsync(Path.Join(directory, ".keystore"), onDisk, TestContext.Current.CancellationToken);
+ }
+
+ private static byte[] DeriveLegacyKek(byte[]? entropy)
+ {
+ var salt = System.Text.Encoding.UTF8.GetBytes($"{Environment.MachineName}:{Environment.UserName}");
+ var machineIdentity = $"{Environment.MachineName}:{Environment.UserName}:{Environment.OSVersion}";
+
+ if (entropy is null || entropy.Length == 0)
+ {
+ return Rfc2898DeriveBytes.Pbkdf2(machineIdentity, salt, LegacyPbkdf2Iterations, HashAlgorithmName.SHA256, LegacyKeySize);
+ }
+
+ var machineBytes = System.Text.Encoding.UTF8.GetBytes(machineIdentity);
+ var password = new byte[entropy.Length + 1 + machineBytes.Length];
+ Buffer.BlockCopy(entropy, 0, password, 0, entropy.Length);
+ password[entropy.Length] = 0x00;
+ Buffer.BlockCopy(machineBytes, 0, password, entropy.Length + 1, machineBytes.Length);
+ return Rfc2898DeriveBytes.Pbkdf2(password, salt, LegacyPbkdf2Iterations, HashAlgorithmName.SHA256, LegacyKeySize);
+ }
+
+ private static byte[] LegacyGcmEncrypt(byte[] key, byte[] plaintext)
+ {
+ var nonce = RandomNumberGenerator.GetBytes(LegacyNonceSize);
+ var ciphertext = new byte[plaintext.Length];
+ var tag = new byte[LegacyTagSize];
+
+ using var aes = new AesGcm(key, LegacyTagSize);
+ aes.Encrypt(nonce, plaintext, ciphertext, tag);
+
+ var output = new byte[LegacyNonceSize + LegacyTagSize + ciphertext.Length];
+ Buffer.BlockCopy(nonce, 0, output, 0, LegacyNonceSize);
+ Buffer.BlockCopy(tag, 0, output, LegacyNonceSize, LegacyTagSize);
+ Buffer.BlockCopy(ciphertext, 0, output, LegacyNonceSize + LegacyTagSize, ciphertext.Length);
+ return output;
+ }
+
[Fact]
public async Task Keystore_WrittenByThisVersion_CarriesFormatHeader()
{
@@ -357,27 +436,80 @@ public async Task Keystore_WrittenByThisVersion_CarriesFormatHeader()
var bytes = await File.ReadAllBytesAsync(Path.Join(temp.Path, ".keystore"), TestContext.Current.CancellationToken);
Assert.True(bytes.Length > KeystoreMagic.Length + 1);
Assert.Equal(KeystoreMagic, bytes[..KeystoreMagic.Length]);
- Assert.Equal(1, bytes[KeystoreMagic.Length]); // format version
+ Assert.Equal(2, bytes[KeystoreMagic.Length]); // format version (2 = OSVersion-free KEK)
}
[Fact]
- public async Task Keystore_LegacyHeaderless_IsStillReadable()
+ public async Task Keystore_LegacyV1_IsMigratedToV2_AndStillDecrypts()
+ {
+ using var temp = new TempDir();
+ var keystorePath = Path.Join(temp.Path, ".keystore");
+
+ // Produce a keystore + ciphertext, then replace the keystore with a
+ // genuine version-1 one (sealed under the old OSVersion-based KEK)
+ // wrapping the same data key — the on-disk shape a pre-v2 library
+ // would have left behind.
+ var writer = new LocalFileCredentialEncryption(temp.Path);
+ var cipher = await writer.EncryptAsync("bound to a v1 keystore");
+ var dataKey = await GetDataKeyAsync(writer);
+ await WriteLegacyKeystoreAsync(temp.Path, dataKey, withHeader: true);
+
+ // A fresh instance reads it (via the legacy KEK) and re-seals it.
+ var reader = new LocalFileCredentialEncryption(temp.Path);
+ Assert.Equal("bound to a v1 keystore", await reader.DecryptAsync(cipher));
+
+ // The first load migrated the file in place: the version byte is now 2.
+ var bytes = await File.ReadAllBytesAsync(keystorePath, TestContext.Current.CancellationToken);
+ Assert.Equal(KeystoreMagic, bytes[..KeystoreMagic.Length]);
+ Assert.Equal(2, bytes[KeystoreMagic.Length]);
+
+ // And the migrated keystore opens under a further fresh instance.
+ var afterMigration = new LocalFileCredentialEncryption(temp.Path);
+ Assert.Equal("bound to a v1 keystore", await afterMigration.DecryptAsync(cipher));
+ }
+
+ [Fact]
+ public async Task Keystore_LegacyHeaderless_IsMigratedToV2_AndStillReadable()
{
using var temp = new TempDir();
var keystorePath = Path.Join(temp.Path, ".keystore");
- // Produce a keystore, then strip its header to reconstruct the legacy
- // headerless on-disk shape a pre-header library version would have
- // written. A fresh instance must still read ciphertext bound to it.
var writer = new LocalFileCredentialEncryption(temp.Path);
var cipher = await writer.EncryptAsync("bound to a legacy keystore");
+ var dataKey = await GetDataKeyAsync(writer);
- var framed = await File.ReadAllBytesAsync(keystorePath, TestContext.Current.CancellationToken);
- var legacy = framed[(KeystoreMagic.Length + 1)..];
- await File.WriteAllBytesAsync(keystorePath, legacy, TestContext.Current.CancellationToken);
+ // Pre-header on-disk shape: no magic/version, sealed under the old
+ // OSVersion-based KEK.
+ await WriteLegacyKeystoreAsync(temp.Path, dataKey, withHeader: false);
var reader = new LocalFileCredentialEncryption(temp.Path);
Assert.Equal("bound to a legacy keystore", await reader.DecryptAsync(cipher));
+
+ // Migrated up to the current header + version.
+ var bytes = await File.ReadAllBytesAsync(keystorePath, TestContext.Current.CancellationToken);
+ Assert.Equal(KeystoreMagic, bytes[..KeystoreMagic.Length]);
+ Assert.Equal(2, bytes[KeystoreMagic.Length]);
+ }
+
+ [Fact]
+ public async Task Keystore_LegacyV1_WithCallerEntropy_IsMigratedToV2()
+ {
+ using var temp = new TempDir();
+ var keystorePath = Path.Join(temp.Path, ".keystore");
+ var entropy = "deployment-secret"u8.ToArray();
+
+ var writer = new LocalFileCredentialEncryption(temp.Path, entropy);
+ var cipher = await writer.EncryptAsync("v1 with entropy");
+ var dataKey = await GetDataKeyAsync(writer);
+
+ // The legacy KEK also folded the entropy in, so the re-wrap must too.
+ await WriteLegacyKeystoreAsync(temp.Path, dataKey, withHeader: true, entropy: entropy);
+
+ var reader = new LocalFileCredentialEncryption(temp.Path, entropy);
+ Assert.Equal("v1 with entropy", await reader.DecryptAsync(cipher));
+
+ var bytes = await File.ReadAllBytesAsync(keystorePath, TestContext.Current.CancellationToken);
+ Assert.Equal(2, bytes[KeystoreMagic.Length]);
}
[Fact]