From f7405b6089c7c4c7b572f3ce45ffa908b2135f8d Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Mon, 21 Sep 2026 06:02:07 +0000 Subject: [PATCH] fix: derive local keystore KEK without OSVersion so OS updates don't break it The file backend's key-encryption key was derived from `{MachineName}:{UserName}:{OSVersion}`, so a Windows feature update (e.g. 25H2 -> 26H2) changed `Environment.OSVersion`, rotated the KEK, and left every credential undecryptable with `AuthenticationTagMismatchException` and no migration path. Drop `OSVersion` from the KEK: it derives from stable machine/user identity plus a fixed domain tag now, so later OS updates cannot rotate it. Machine and user name still bind the keystore to this machine/user, and the security boundary is unchanged (filesystem permissions on the credentials directory). Bump the keystore format to version 2. 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 overwrites in place (safe: the data key is unchanged) and is best-effort, so a persistence failure never fails an otherwise-successful read. A store already broken by an OS update performed before this upgrade cannot be recovered by the library; the integrity-error message now names that cause. Tests: add v1->v2, legacy-headerless->v2, and entropy v1->v2 migration coverage asserting the on-disk version flips to 2 and decryption still works; update the format-header test. Full suite green on net8.0 and net10.0. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 25 ++ .../LocalFileCredentialEncryption.cs | 215 +++++++++++++----- .../LocalFileCredentialEncryptionTests.cs | 148 +++++++++++- 3 files changed, 322 insertions(+), 66 deletions(-) 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]