feat: add Ford VP44 unlock progress dialog, K-Line fast unlock, localization, safety dialogs, and settings
Unlock progress UI:
- UnlockProgressDialog with dark-themed progress ring, phase indicator, elapsed
time, and cancel/close buttons (non-modal, draggable borderless window)
- UnlockProgressViewModel with event-driven progress tracking via IUnlockService
- Triggers on pump selection (manual or K-Line auto-detect), not test start
UnlockService rewrite:
- Persistent CAN senders that outlive the unlock sequence (StopSenders on pump change)
- Concurrent K-Line fast unlock: awaits session Connected, sends RAM timer shortcut
({02 88 02 03 A8 01 00}), verifies via CAN TestUnlock before skipping wait
- Fix Type 1 verification (Value == 0 means unlocked, was inverted)
K-Line fast unlock support:
- IKwpService.TryFastUnlockAsync / KwpService implementation
Additional features:
- ILocalizationService with ES/EN resource dictionaries and runtime switching
- Safety dialogs: VoltageWarning, OilPumpConfirm, RpmSafetyWarning
- SettingsDialog for app configuration
- BenchService enhancements, ConfigurationService improvements, PDF report updates
- All UI strings localized via DynamicResource
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,8 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Xml.Linq;
|
||||
using HC_APTBS.Models;
|
||||
using Peak.Can.Basic;
|
||||
@@ -460,6 +462,18 @@ namespace HC_APTBS.Services.Impl
|
||||
_log.Error(LogId, $"LoadSettings failed: {ex.Message}");
|
||||
}
|
||||
|
||||
// Seed default admin account if no users are configured.
|
||||
if (string.IsNullOrEmpty(_settings.Users))
|
||||
{
|
||||
var (salt, hash) = HashPassword("admin");
|
||||
_settings.Users = $"admin:{salt}:{hash}";
|
||||
_log.Info(LogId, "No users configured — created default 'admin' account.");
|
||||
SaveSettings();
|
||||
}
|
||||
|
||||
// Migrate plaintext user:password entries to hashed format.
|
||||
MigrateUsersIfNeeded();
|
||||
|
||||
LoadSensors();
|
||||
LoadClients();
|
||||
LoadAlarms();
|
||||
@@ -742,7 +756,83 @@ namespace HC_APTBS.Services.Impl
|
||||
catch { }
|
||||
}
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────────────────
|
||||
// ── Users (PBKDF2-HMAC-SHA256 hashed credentials) ─────────────────────────
|
||||
|
||||
private const int SaltBytes = 16;
|
||||
private const int HashBytes = 32;
|
||||
private const int Pbkdf2Iterations = 600_000;
|
||||
|
||||
/// <summary>Generates a random salt and computes the PBKDF2-HMAC-SHA256 hash for <paramref name="password"/>.</summary>
|
||||
private static (string salt, string hash) HashPassword(string password)
|
||||
{
|
||||
byte[] salt = RandomNumberGenerator.GetBytes(SaltBytes);
|
||||
byte[] hash = Rfc2898DeriveBytes.Pbkdf2(
|
||||
password, salt, Pbkdf2Iterations, HashAlgorithmName.SHA256, HashBytes);
|
||||
return (Convert.ToBase64String(salt), Convert.ToBase64String(hash));
|
||||
}
|
||||
|
||||
/// <summary>Verifies <paramref name="password"/> against the given Base64 <paramref name="salt"/> and <paramref name="expectedHash"/>.</summary>
|
||||
private static bool VerifyPassword(string password, string salt, string expectedHash)
|
||||
{
|
||||
byte[] saltBytes = Convert.FromBase64String(salt);
|
||||
byte[] computed = Rfc2898DeriveBytes.Pbkdf2(
|
||||
password, saltBytes, Pbkdf2Iterations, HashAlgorithmName.SHA256, HashBytes);
|
||||
return CryptographicOperations.FixedTimeEquals(computed, Convert.FromBase64String(expectedHash));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects whether <see cref="AppSettings.Users"/> contains legacy plaintext
|
||||
/// <c>user:password</c> entries and migrates them to <c>user:salt:hash</c>.
|
||||
/// </summary>
|
||||
private void MigrateUsersIfNeeded()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Settings.Users))
|
||||
return;
|
||||
|
||||
string[] entries = Settings.Users.Split(',');
|
||||
bool hasLegacy = false;
|
||||
|
||||
foreach (string entry in entries)
|
||||
{
|
||||
// New format always has exactly 3 colon-separated parts (user:salt:hash).
|
||||
// Legacy format has exactly 2 parts (user:password).
|
||||
// Base64 salt/hash never contain commas but may contain '=' padding —
|
||||
// they will NOT contain additional colons, so Split(':') count is reliable.
|
||||
string[] parts = entry.Split(':');
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
hasLegacy = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasLegacy) return;
|
||||
|
||||
var migrated = new List<string>(entries.Length);
|
||||
foreach (string entry in entries)
|
||||
{
|
||||
string[] parts = entry.Split(':');
|
||||
if (parts.Length == 2 && parts[0].Length > 0)
|
||||
{
|
||||
// Legacy entry — hash the plaintext password.
|
||||
var (salt, hash) = HashPassword(parts[1]);
|
||||
migrated.Add($"{parts[0]}:{salt}:{hash}");
|
||||
}
|
||||
else if (parts.Length == 3 && parts[0].Length > 0)
|
||||
{
|
||||
// Already migrated entry — keep as-is.
|
||||
migrated.Add(entry);
|
||||
}
|
||||
else
|
||||
{
|
||||
_log.Warning(LogId, $"Skipped malformed user entry during migration: '{entry}'");
|
||||
}
|
||||
}
|
||||
|
||||
Settings.Users = string.Join(",", migrated);
|
||||
SaveSettings();
|
||||
_log.Info(LogId, $"Migrated {entries.Length} user credential(s) from plaintext to PBKDF2 hashed format.");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool ValidateUser(string username, string password)
|
||||
@@ -750,25 +840,26 @@ namespace HC_APTBS.Services.Impl
|
||||
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
|
||||
return false;
|
||||
|
||||
string check = username + ":" + password;
|
||||
foreach (string entry in Settings.Users.Split(','))
|
||||
{
|
||||
if (entry == check) return true;
|
||||
string[] parts = entry.Split(':');
|
||||
if (parts.Length == 3 && parts[0] == username)
|
||||
return VerifyPassword(password, parts[1], parts[2]);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyDictionary<string, string> GetUsers()
|
||||
public IReadOnlyList<string> GetUsers()
|
||||
{
|
||||
var dict = new Dictionary<string, string>();
|
||||
foreach (string kv in Settings.Users.Split(','))
|
||||
var names = new List<string>();
|
||||
foreach (string entry in Settings.Users.Split(','))
|
||||
{
|
||||
string[] parts = kv.Split(':');
|
||||
if (parts.Length == 2 && parts[0].Length > 0)
|
||||
dict[parts[0]] = parts[1];
|
||||
string[] parts = entry.Split(':');
|
||||
if (parts.Length == 3 && parts[0].Length > 0)
|
||||
names.Add(parts[0]);
|
||||
}
|
||||
return dict;
|
||||
return names;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -776,7 +867,10 @@ namespace HC_APTBS.Services.Impl
|
||||
{
|
||||
var entries = new List<string>(users.Count);
|
||||
foreach (var kv in users)
|
||||
entries.Add(kv.Key + ":" + kv.Value);
|
||||
{
|
||||
var (salt, hash) = HashPassword(kv.Value);
|
||||
entries.Add($"{kv.Key}:{salt}:{hash}");
|
||||
}
|
||||
|
||||
Settings.Users = string.Join(",", entries);
|
||||
SaveSettings();
|
||||
|
||||
Reference in New Issue
Block a user