Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 96 additions & 4 deletions src/NodeRadarPro/Data/CredentialVault.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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);
Expand Down
43 changes: 43 additions & 0 deletions tests/NodeRadarPro.Tests/Data/CredentialVaultTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}