From 09851dea0d15b25fae5882c7aed5106c83fde62d Mon Sep 17 00:00:00 2001 From: Ahmed-Yaseen99 <186510963+Ahmed-Yaseen99@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:51:07 +0000 Subject: [PATCH] security: replace predictable PBKDF2 password with random secret file --- src/NodeRadarPro/Data/CredentialVault.cs | 100 +++++++++++++++++- .../Data/CredentialVaultTests.cs | 43 ++++++++ 2 files changed, 139 insertions(+), 4 deletions(-) diff --git a/src/NodeRadarPro/Data/CredentialVault.cs b/src/NodeRadarPro/Data/CredentialVault.cs index 7aa705a..2f793c3 100644 --- a/src/NodeRadarPro/Data/CredentialVault.cs +++ b/src/NodeRadarPro/Data/CredentialVault.cs @@ -157,11 +157,24 @@ internal static byte[] UnprotectData(byte[] data) byte[] key = DeriveKeyPbkdf2(salt); byte[] plainBytes = new byte[cipherText.Length]; - using (var aesGcm = new AesGcm(key, 16)) + try { - aesGcm.Decrypt(nonce, cipherText, tag, plainBytes); + using (var aesGcm = new AesGcm(key, 16)) + { + aesGcm.Decrypt(nonce, cipherText, tag, plainBytes); + } + return plainBytes; + } + catch (CryptographicException) + { + // Fallback to legacy machine-name based key derivation for backward compatibility + byte[] legacyKey = DeriveKeyPbkdf2Legacy(salt); + using (var aesGcm = new AesGcm(legacyKey, 16)) + { + aesGcm.Decrypt(nonce, cipherText, tag, plainBytes); + } + return plainBytes; } - return plainBytes; } catch { @@ -197,9 +210,88 @@ internal static byte[] UnprotectData(byte[] data) } } - // ── Internal Key Derivation ── + // ── Internal Key Derivation & Secret Management ── + + private static readonly object SecretLock = new(); + private static byte[]? _cachedVaultSecret; + + internal static byte[] GetOrCreateVaultSecret() + { + lock (SecretLock) + { + if (_cachedVaultSecret != null) + { + return (byte[])_cachedVaultSecret.Clone(); + } + + string dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "NodeRadarPro"); + string secretFile = Path.Combine(dir, ".vault_secret"); + + if (File.Exists(secretFile)) + { + try + { + byte[] secretBytes = File.ReadAllBytes(secretFile); + if (secretBytes.Length == 32) + { + _cachedVaultSecret = secretBytes; + return (byte[])secretBytes.Clone(); + } + } + catch + { + // Fallback to generating a new key if read fails + } + } + + byte[] newSecret = new byte[32]; + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(newSecret); + } + + try + { + Directory.CreateDirectory(dir); + File.WriteAllBytes(secretFile, newSecret); + + if (!OperatingSystem.IsWindows()) + { + try + { + File.SetUnixFileMode(secretFile, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + catch + { + // Platform or filesystem doesn't support Unix file permissions + } + } + } + catch + { + // Fallback ignore writing error, keep in-memory secret + } + + _cachedVaultSecret = newSecret; + return (byte[])newSecret.Clone(); + } + } + + internal static void ResetCachedSecretForTesting() + { + lock (SecretLock) + { + _cachedVaultSecret = null; + } + } internal static byte[] DeriveKeyPbkdf2(byte[] salt) + { + byte[] secret = GetOrCreateVaultSecret(); + return Rfc2898DeriveBytes.Pbkdf2(secret, salt, 100000, HashAlgorithmName.SHA256, 32); + } + + internal static byte[] DeriveKeyPbkdf2Legacy(byte[] salt) { string password = $"{Environment.MachineName}_{Environment.UserName}_NodeRadarPro_Pbkdf2Secret"; return Rfc2898DeriveBytes.Pbkdf2(password, salt, 100000, HashAlgorithmName.SHA256, 32); diff --git a/tests/NodeRadarPro.Tests/Data/CredentialVaultTests.cs b/tests/NodeRadarPro.Tests/Data/CredentialVaultTests.cs index ea6f226..7b55311 100644 --- a/tests/NodeRadarPro.Tests/Data/CredentialVaultTests.cs +++ b/tests/NodeRadarPro.Tests/Data/CredentialVaultTests.cs @@ -202,4 +202,47 @@ public void UnprotectData_WithCorruptedPayload_ReturnsDataWithoutThrowing() byte[] result = CredentialVault.UnprotectData(corruptPayload); Assert.Equal(corruptPayload, result); } + [Fact] + public void GetOrCreateVaultSecret_Returns32BytesAndCaches() + { + CredentialVault.ResetCachedSecretForTesting(); + byte[] secret1 = CredentialVault.GetOrCreateVaultSecret(); + byte[] secret2 = CredentialVault.GetOrCreateVaultSecret(); + + Assert.NotNull(secret1); + Assert.Equal(32, secret1.Length); + Assert.Equal(secret1, secret2); + } + + [Fact] + public void UnprotectData_WithLegacyV2MachineNamePayload_DecryptsCorrectly() + { + byte[] salt = new byte[16]; + byte[] nonce = new byte[12]; + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(salt); + rng.GetBytes(nonce); + } + + byte[] legacyKey = CredentialVault.DeriveKeyPbkdf2Legacy(salt); + byte[] plainBytes = Encoding.UTF8.GetBytes("LegacyV2MachineNameSecretPayload"); + byte[] cipherText = new byte[plainBytes.Length]; + byte[] tag = new byte[16]; + + using (var aesGcm = new AesGcm(legacyKey, 16)) + { + aesGcm.Encrypt(nonce, plainBytes, cipherText, tag); + } + + byte[] payload = new byte[1 + 16 + 12 + 16 + cipherText.Length]; + payload[0] = 0x02; + Buffer.BlockCopy(salt, 0, payload, 1, 16); + Buffer.BlockCopy(nonce, 0, payload, 17, 12); + Buffer.BlockCopy(tag, 0, payload, 29, 16); + Buffer.BlockCopy(cipherText, 0, payload, 45, cipherText.Length); + + byte[] decrypted = CredentialVault.UnprotectData(payload); + Assert.Equal("LegacyV2MachineNameSecretPayload", Encoding.UTF8.GetString(decrypted)); + } }