All changes are localized to RemoteWebServer plus two small helpers; the Phase 0 characterization tests confirm routing, JSON/Prometheus shape, and credential pass/fail semantics are otherwise unchanged. - Password hashing: new PasswordHasher uses PBKDF2-HMAC-SHA256 with a per-credential random salt (self-describing pbkdf2$iters$salt$hash). Verify() still accepts the legacy unsalted SHA-256 hex hash and a successful legacy auth transparently upgrades the stored hash, persisted by the view model on save/shutdown. Property renamed PasswordSHA256 -> PasswordHash. - Constant-time comparison: CredentialComparer.FixedTimeEquals for the user name and password hash; both are evaluated fully (no && short-circuit). - No information disclosure: POST failures return a generic message instead of ex.ToString(); detail is logged server-side only. - Bind intent respected: ResolveListenerIp no longer mutates ListenerIp or silently falls back to all-interfaces for a specific configured address (auto/'?'/wildcards still bind all). A bad address now fails Start(). - CORS: removed the Access-Control-Allow-Origin '*' wildcard; common response headers centralized in WriteCommonHeaders. - Prometheus: label values are escaped (EscapePrometheusLabel). 185 tests pass (was 167). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
88 lines
3.5 KiB
C#
88 lines
3.5 KiB
C#
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
|
// If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
// Copyright (C) LibreHardwareMonitor and Contributors.
|
|
|
|
using System;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
|
|
namespace LibreHardwareMonitor.Windows.WinUI.Services;
|
|
|
|
/// <summary>
|
|
/// Hashes and verifies the remote web server's Basic-auth password.
|
|
/// <para>
|
|
/// New credentials use PBKDF2-HMAC-SHA256 with a per-credential random salt, stored in the self-describing format
|
|
/// <c>pbkdf2$<iterations>$<base64 salt>$<base64 hash></c>. <see cref="Verify" /> also accepts the
|
|
/// legacy unsalted SHA-256 hex hash that older configurations stored, so existing <c>authenticationPassword</c>
|
|
/// settings keep working; callers can then opportunistically re-hash to upgrade them.
|
|
/// </para>
|
|
/// </summary>
|
|
internal static class PasswordHasher
|
|
{
|
|
private const string Pbkdf2Prefix = "pbkdf2$";
|
|
private const int Iterations = 100_000;
|
|
private const int SaltSize = 16;
|
|
private const int KeySize = 32;
|
|
|
|
/// <summary>Produces a salted PBKDF2 hash string for <paramref name="password" />.</summary>
|
|
public static string Hash(string password)
|
|
{
|
|
byte[] salt = RandomNumberGenerator.GetBytes(SaltSize);
|
|
byte[] key = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, KeySize);
|
|
return $"{Pbkdf2Prefix}{Iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(key)}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies <paramref name="password" /> against <paramref name="storedHash" /> in constant time.
|
|
/// <paramref name="isLegacy" /> reports whether the stored hash used the old unsalted SHA-256 scheme, so the caller
|
|
/// can transparently upgrade it.
|
|
/// </summary>
|
|
public static bool Verify(string password, string storedHash, out bool isLegacy)
|
|
{
|
|
isLegacy = false;
|
|
if (string.IsNullOrEmpty(storedHash))
|
|
return false;
|
|
|
|
if (storedHash.StartsWith(Pbkdf2Prefix, StringComparison.Ordinal))
|
|
return VerifyPbkdf2(password, storedHash);
|
|
|
|
// Legacy unsalted SHA-256 hex hash.
|
|
isLegacy = true;
|
|
return CredentialComparer.FixedTimeEquals(ComputeLegacySha256(password), storedHash);
|
|
}
|
|
|
|
/// <summary>Lowercase hex SHA-256, matching the hash older versions stored. Kept only to verify/upgrade legacy values.</summary>
|
|
public static string ComputeLegacySha256(string text)
|
|
{
|
|
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(text));
|
|
return string.Concat(hash.Select(b => b.ToString("x2", CultureInfo.InvariantCulture)));
|
|
}
|
|
|
|
private static bool VerifyPbkdf2(string password, string storedHash)
|
|
{
|
|
string[] parts = storedHash.Split('$');
|
|
if (parts.Length != 4)
|
|
return false;
|
|
|
|
if (!int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int iterations) || iterations <= 0)
|
|
return false;
|
|
|
|
byte[] salt;
|
|
byte[] expected;
|
|
try
|
|
{
|
|
salt = Convert.FromBase64String(parts[2]);
|
|
expected = Convert.FromBase64String(parts[3]);
|
|
}
|
|
catch (FormatException)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
byte[] actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, expected.Length);
|
|
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
|
}
|
|
}
|