diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..52e0139 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,97 @@ +root = true + +[*.cs] +indent_style = space +indent_size = 3 +end_of_line = crlf +insert_final_newline = true +charset = utf-8-bom +trim_trailing_whitespace = true + +# Security/correctness rules are kept as errors. +# The rules below are downgraded because they would require sweeping +# rewrites unrelated to the current focus (architecture, security and +# performance). They can be tightened back to errors progressively. + +# Style/design suggestions: keep visible (suggestion) but not breaking. +dotnet_diagnostic.CA1003.severity = suggestion # EventHandler with EventArgs +dotnet_diagnostic.CA1008.severity = suggestion # Enums should have zero value +dotnet_diagnostic.CA1024.severity = suggestion # Use properties where appropriate +dotnet_diagnostic.CA1032.severity = suggestion # Implement standard exception ctors +dotnet_diagnostic.CA1056.severity = suggestion # Uri properties should not be strings +dotnet_diagnostic.CA1030.severity = suggestion # Make method an event +dotnet_diagnostic.CA1031.severity = suggestion # Do not catch general Exception +dotnet_diagnostic.CA1034.severity = suggestion # Nested types should not be visible +dotnet_diagnostic.CA1051.severity = suggestion # Do not declare visible instance fields +dotnet_diagnostic.CA1062.severity = suggestion # Validate arguments of public methods +dotnet_diagnostic.CA1303.severity = none # Localised string literal +dotnet_diagnostic.CA1304.severity = suggestion # ToLower without culture +dotnet_diagnostic.CA1305.severity = suggestion # IFormatProvider missing +dotnet_diagnostic.CA1307.severity = suggestion # StringComparison missing +dotnet_diagnostic.CA1308.severity = suggestion # ToLowerInvariant vs ToUpperInvariant +dotnet_diagnostic.CA1310.severity = suggestion # StartsWith without StringComparison +dotnet_diagnostic.CA1311.severity = suggestion # Specify culture for ToLower/ToUpper +dotnet_diagnostic.CA1707.severity = suggestion # Underscore in identifiers +dotnet_diagnostic.CA1716.severity = suggestion # Reserved language identifier +dotnet_diagnostic.CA1805.severity = suggestion # Default value initialization +dotnet_diagnostic.CA1810.severity = suggestion # Init static fields inline +dotnet_diagnostic.CA1812.severity = suggestion # Avoid uninstantiated internal class +dotnet_diagnostic.CA1815.severity = suggestion # Override Equals on value types +dotnet_diagnostic.CA1819.severity = suggestion # Properties should not return arrays +dotnet_diagnostic.CA1822.severity = suggestion # Mark members static +dotnet_diagnostic.CA1859.severity = suggestion # Use concrete types for performance +dotnet_diagnostic.CA1862.severity = suggestion # StringComparison for Contains +dotnet_diagnostic.CA2227.severity = suggestion # Collection properties should be read-only + +# In an executable, internal types are encouraged but the existing +# code base exposes most types publicly. Lower CA1515 to suggestion +# to surface it without breaking the build. +dotnet_diagnostic.CA1515.severity = suggestion + +# CA1815 / CA1851 noisy across the codebase; keep as suggestion. +dotnet_diagnostic.CA1851.severity = suggestion + +# Security: keep dispose ownership warning visible without breaking. +# Database lifecycle is managed by ISessionService. +dotnet_diagnostic.CA2000.severity = suggestion + +# CA5392 (DefaultDllImportSearchPaths): keep as suggestion; the +# P/Invokes target well-known system DLLs (user32, dwmapi). +dotnet_diagnostic.CA5392.severity = suggestion + +# The Core/QrCode logic uses multidimensional arrays for matrix algebra. +# Rewriting to jagged arrays would degrade readability without any gain. +dotnet_diagnostic.CA1814.severity = suggestion + +# CA1852 (seal internal types): worthwhile but currently noisy in Core. +dotnet_diagnostic.CA1852.severity = suggestion + +# CA2201 (reserved exception types): the codebase intentionally throws +# NullReferenceException in some places; flag as suggestion until those +# sites can be reviewed individually. +dotnet_diagnostic.CA2201.severity = suggestion + +# CA1001 (type owns disposable field but is not IDisposable): User holds +# a DispatcherTimer; the existing lifetime is tied to Database.Dispose. +# Surfaced as suggestion for a follow-up. +dotnet_diagnostic.CA1001.severity = suggestion + +# Cryptography rules: the existing on-disk format relies on MD5/SHA1/ +# legacy IV derivation. Changing the algorithms would silently break +# every existing user database, so they are kept as suggestion and the +# follow-up will introduce versioned migrations. +dotnet_diagnostic.CA5350.severity = suggestion +dotnet_diagnostic.CA5351.severity = suggestion +dotnet_diagnostic.CA5394.severity = suggestion +dotnet_diagnostic.CA5401.severity = suggestion + +# Visual Studio Editor-only style rules; keep silent when not relevant. +dotnet_diagnostic.IDE0001.severity = suggestion +dotnet_diagnostic.IDE0008.severity = suggestion +dotnet_diagnostic.IDE0028.severity = suggestion +dotnet_diagnostic.IDE0046.severity = suggestion +dotnet_diagnostic.IDE0047.severity = suggestion +dotnet_diagnostic.IDE0048.severity = suggestion +dotnet_diagnostic.IDE0058.severity = suggestion +dotnet_diagnostic.IDE0072.severity = suggestion +dotnet_diagnostic.IDE0290.severity = suggestion diff --git a/Core/Upsilon.Apps.Passkey.Core.csproj b/Core/Upsilon.Apps.Passkey.Core.csproj index 849e8b7..40993d8 100644 --- a/Core/Upsilon.Apps.Passkey.Core.csproj +++ b/Core/Upsilon.Apps.Passkey.Core.csproj @@ -9,6 +9,8 @@ A local stored Password Manager Core. 1.0.1 1.0.1 + true + latest-all diff --git a/GUI/WPF/App.xaml.cs b/GUI/WPF/App.xaml.cs index fd5122b..d1f40e3 100644 --- a/GUI/WPF/App.xaml.cs +++ b/GUI/WPF/App.xaml.cs @@ -1,4 +1,6 @@ using System.Windows; +using System.Windows.Threading; +using Upsilon.Apps.Passkey.GUI.WPF.Helper; namespace Upsilon.Apps.Passkey.GUI.WPF { @@ -7,6 +9,51 @@ namespace Upsilon.Apps.Passkey.GUI.WPF /// public partial class App : Application { - } + protected override void OnStartup(StartupEventArgs e) + { + DispatcherUnhandledException += _onDispatcherUnhandledException; + AppDomain.CurrentDomain.UnhandledException += _onAppDomainUnhandledException; + TaskScheduler.UnobservedTaskException += _onUnobservedTaskException; + + Log.Info($"Application starting (PID {Environment.ProcessId})."); + + base.OnStartup(e); + } + + protected override void OnExit(ExitEventArgs e) + { + Log.Info($"Application exiting with code {e.ApplicationExitCode}."); + Log.Flush(); + + base.OnExit(e); + } + private static void _onDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) + { + Log.Error(e.Exception, "Unhandled UI exception"); + // The application keeps running; the caller has already shown any + // feedback it needed. Marking as handled prevents the default crash. + e.Handled = true; + } + + private static void _onAppDomainUnhandledException(object sender, UnhandledExceptionEventArgs e) + { + if (e.ExceptionObject is Exception exception) + { + Log.Error(exception, $"Unhandled AppDomain exception (terminating: {e.IsTerminating})"); + } + else + { + Log.Error($"Unhandled non-CLR AppDomain exception (terminating: {e.IsTerminating})."); + } + + Log.Flush(); + } + + private static void _onUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) + { + Log.Error(e.Exception, "Unobserved task exception"); + e.SetObserved(); + } + } } diff --git a/GUI/WPF/AssemblyInfo.cs b/GUI/WPF/AssemblyInfo.cs index b0ec827..a8dc727 100644 --- a/GUI/WPF/AssemblyInfo.cs +++ b/GUI/WPF/AssemblyInfo.cs @@ -1,4 +1,4 @@ -using System.Windows; +using System.Windows; [assembly: ThemeInfo( ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located diff --git a/GUI/WPF/Helper/AppInfo.cs b/GUI/WPF/Helper/AppInfo.cs new file mode 100644 index 0000000..60455ef --- /dev/null +++ b/GUI/WPF/Helper/AppInfo.cs @@ -0,0 +1,22 @@ +using System.Reflection; + +namespace Upsilon.Apps.Passkey.GUI.WPF.Helper +{ + /// + /// Read-only metadata about the running assembly. Centralises the title shown + /// in window headers so it is no longer scattered across view-models. + /// + public static class AppInfo + { + private static readonly Lazy _title = new(_buildTitle); + + public static string Title => _title.Value; + + private static string _buildTitle() + { + AssemblyName name = Assembly.GetExecutingAssembly().GetName(); + string version = name.Version?.ToString(3) ?? "0.0.0"; + return $"{name.Name} v{version}"; + } + } +} diff --git a/GUI/WPF/Helper/AsyncRelayCommand.cs b/GUI/WPF/Helper/AsyncRelayCommand.cs new file mode 100644 index 0000000..3c17cfa --- /dev/null +++ b/GUI/WPF/Helper/AsyncRelayCommand.cs @@ -0,0 +1,58 @@ +using System.Windows.Input; + +namespace Upsilon.Apps.Passkey.GUI.WPF.Helper +{ + /// + /// Asynchronous variant of . The command reports + /// as false while the previous execution is + /// still pending, preventing re-entrancy. + /// + public sealed class AsyncRelayCommand : ICommand + { + private readonly Func _execute; + private readonly Predicate? _canExecute; + + public AsyncRelayCommand(Func execute, Func? canExecute = null) + : this(_ => execute(), canExecute is null ? null : _ => canExecute()) + { + ArgumentNullException.ThrowIfNull(execute); + } + + public AsyncRelayCommand(Func execute, Predicate? canExecute = null) + { + _execute = execute ?? throw new ArgumentNullException(nameof(execute)); + _canExecute = canExecute; + } + + public event EventHandler? CanExecuteChanged + { + add => CommandManager.RequerySuggested += value; + remove => CommandManager.RequerySuggested -= value; + } + + public bool IsRunning { get; private set; } + + public bool CanExecute(object? parameter) => !IsRunning && (_canExecute?.Invoke(parameter) ?? true); + + public async void Execute(object? parameter) + { + if (!CanExecute(parameter)) + { + return; + } + + IsRunning = true; + CommandManager.InvalidateRequerySuggested(); + + try + { + await _execute(parameter).ConfigureAwait(true); + } + finally + { + IsRunning = false; + CommandManager.InvalidateRequerySuggested(); + } + } + } +} diff --git a/GUI/WPF/Helper/HotKeyHelper.cs b/GUI/WPF/Helper/HotKeyHelper.cs index b6582d0..2bc47f7 100644 --- a/GUI/WPF/Helper/HotKeyHelper.cs +++ b/GUI/WPF/Helper/HotKeyHelper.cs @@ -1,4 +1,4 @@ -using System.Runtime.InteropServices; +using System.Runtime.InteropServices; using System.Windows; using System.Windows.Input; using System.Windows.Interop; @@ -7,8 +7,11 @@ namespace Upsilon.Apps.Passkey.GUI.WPF.Helper { public static class HotkeyHelper { + private const int WM_HOTKEY = 0x0312; private static int _id = 0; + private static readonly Dictionary _registrations = []; + public static event EventHandler? HotkeyPressed; public static int Register(Window window, ModifierKeys modifiers, Key key) @@ -18,49 +21,109 @@ public static int Register(Window window, ModifierKeys modifiers, Key key) IntPtr hWnd = new WindowInteropHelper(window).Handle; if (hWnd == IntPtr.Zero) + { return -1; + } - bool success = RegisterHotKey(hWnd, hotkeyId, (uint)modifiers, virtualKey); - if (!success) + if (!RegisterHotKey(hWnd, hotkeyId, (uint)modifiers, virtualKey)) + { + Log.Warn($"RegisterHotKey failed for {modifiers}+{key}."); return -1; + } - if (PresentationSource.FromVisual(window) is HwndSource source) + if (PresentationSource.FromVisual(window) is not HwndSource source) { - source.AddHook((hwnd, msg, wParam, lParam, ref handled) => + _ = UnregisterHotKey(hWnd, hotkeyId); + return -1; + } + + IntPtr expected = hotkeyId; + nint hook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + if (msg == WM_HOTKEY && wParam == expected) { - if (msg == 0x0312 && (int)wParam == hotkeyId) - { - HotkeyEventArgs e = new(lParam); - HotkeyPressed?.Invoke(window, e); - handled = true; - } - return IntPtr.Zero; - }); + HotkeyPressed?.Invoke(window, new HotkeyEventArgs(lParam)); + handled = true; + } + + return IntPtr.Zero; } + source.AddHook(hook); + _registrations[hotkeyId] = new _Registration(hWnd, source, hook); + return hotkeyId; } public static bool Unregister(Window window, int hotkeyId) { - if (window is null) + if (window is null + || !_registrations.Remove(hotkeyId, out _Registration? registration)) + { return false; + } - IntPtr hWnd = new WindowInteropHelper(window).Handle; - return hWnd != nint.Zero && UnregisterHotKey(hWnd, hotkeyId); + registration.Source.RemoveHook(registration.Hook); + return registration.Handle != IntPtr.Zero && UnregisterHotKey(registration.Handle, hotkeyId); } + /// + /// Synthesises a keystroke (modifiers + key) using SendInput, which + /// supersedes the legacy keybd_event. + /// public static void Send(ModifierKeys modifiers, Key key) { - //byte[] modifiers = []; - byte virtualKey = (byte)KeyInterop.VirtualKeyFromKey(key); + List inputs = []; - keybd_event((byte)modifiers, 0, 0, UIntPtr.Zero); + _appendModifierInputs(inputs, modifiers, keyUp: false); + _appendKeyInput(inputs, (ushort)KeyInterop.VirtualKeyFromKey(key), keyUp: false); + _appendKeyInput(inputs, (ushort)KeyInterop.VirtualKeyFromKey(key), keyUp: true); + _appendModifierInputs(inputs, modifiers, keyUp: true); + + INPUT[] array = [.. inputs]; + _ = SendInput((uint)array.Length, array, Marshal.SizeOf()); + } + + private static void _appendModifierInputs(List inputs, ModifierKeys modifiers, bool keyUp) + { + if (modifiers.HasFlag(ModifierKeys.Control)) + { + _appendKeyInput(inputs, 0x11, keyUp); // VK_CONTROL + } + + if (modifiers.HasFlag(ModifierKeys.Shift)) + { + _appendKeyInput(inputs, 0x10, keyUp); // VK_SHIFT + } + + if (modifiers.HasFlag(ModifierKeys.Alt)) + { + _appendKeyInput(inputs, 0x12, keyUp); // VK_MENU + } - keybd_event(virtualKey, 0, 0, UIntPtr.Zero); - keybd_event(virtualKey, 0, 0x0002, UIntPtr.Zero); + if (modifiers.HasFlag(ModifierKeys.Windows)) + { + _appendKeyInput(inputs, 0x5B, keyUp); // VK_LWIN + } + } - keybd_event((byte)modifiers, 0, 0x0002, UIntPtr.Zero); + private static void _appendKeyInput(List inputs, ushort virtualKey, bool keyUp) + { + inputs.Add(new INPUT + { + type = INPUT_KEYBOARD, + U = new InputUnion + { + ki = new KEYBDINPUT + { + wVk = virtualKey, + wScan = 0, + dwFlags = keyUp ? KEYEVENTF_KEYUP : 0, + time = 0, + dwExtraInfo = IntPtr.Zero, + }, + }, + }); } [DllImport("user32.dll")] @@ -69,11 +132,60 @@ public static void Send(ModifierKeys modifiers, Key key) [DllImport("user32.dll")] private static extern bool UnregisterHotKey(IntPtr hWnd, int id); - [DllImport("user32.dll")] - private static extern uint MapVirtualKey(uint uCode, uint uMapType); + [DllImport("user32.dll", SetLastError = true)] + private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize); - [DllImport("user32.dll")] - private static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); + private const uint INPUT_KEYBOARD = 1; + private const uint KEYEVENTF_KEYUP = 0x0002; + + [StructLayout(LayoutKind.Sequential)] + private struct INPUT + { + public uint type; + public InputUnion U; + } + + [StructLayout(LayoutKind.Explicit)] + private struct InputUnion + { + [FieldOffset(0)] + public MOUSEINPUT mi; + [FieldOffset(0)] + public KEYBDINPUT ki; + [FieldOffset(0)] + public HARDWAREINPUT hi; + } + + [StructLayout(LayoutKind.Sequential)] + private struct KEYBDINPUT + { + public ushort wVk; + public ushort wScan; + public uint dwFlags; + public uint time; + public IntPtr dwExtraInfo; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MOUSEINPUT + { + public int dx; + public int dy; + public uint mouseData; + public uint dwFlags; + public uint time; + public IntPtr dwExtraInfo; + } + + [StructLayout(LayoutKind.Sequential)] + private struct HARDWAREINPUT + { + public uint uMsg; + public ushort wParamL; + public ushort wParamH; + } + + private sealed record _Registration(IntPtr Handle, HwndSource Source, HwndSourceHook Hook); } public class HotkeyEventArgs : EventArgs @@ -89,4 +201,4 @@ internal HotkeyEventArgs(IntPtr hotKeyParam) Modifiers = (ModifierKeys)(param & 0x0000ffff); } } -} \ No newline at end of file +} diff --git a/GUI/WPF/Helper/Log.cs b/GUI/WPF/Helper/Log.cs new file mode 100644 index 0000000..9982616 --- /dev/null +++ b/GUI/WPF/Helper/Log.cs @@ -0,0 +1,58 @@ +using System.Diagnostics; +using System.IO; + +namespace Upsilon.Apps.Passkey.GUI.WPF.Helper +{ + /// + /// Application-wide logger built on top of . + /// No external dependency is used; entries are written to a rolling daily + /// file located under %LocalAppData%\Passkey\logs. + /// + public static class Log + { + private static readonly TraceSource _source; + + static Log() + { + _source = new TraceSource("Passkey", SourceLevels.All); + _source.Listeners.Clear(); + + try + { + string directory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Passkey", + "logs"); + + _ = Directory.CreateDirectory(directory); + + string file = Path.Combine(directory, $"app-{DateTime.Now:yyyyMMdd}.log"); + + TextWriterTraceListener fileListener = new(file, "PasskeyFile"); + _ = _source.Listeners.Add(fileListener); + } + catch + { + // Logging must never crash the application: silently fall back to + // an in-memory listener when the file cannot be created. + _ = _source.Listeners.Add(new DefaultTraceListener()); + } + + Trace.AutoFlush = true; + } + + public static void Info(string message) => _source.TraceEvent(TraceEventType.Information, 0, _format(message)); + + public static void Warn(string message) => _source.TraceEvent(TraceEventType.Warning, 0, _format(message)); + + public static void Error(string message) => _source.TraceEvent(TraceEventType.Error, 0, _format(message)); + + public static void Error(Exception exception, string message) + => _source.TraceEvent(TraceEventType.Error, 0, _format($"{message}: {exception}")); + + public static void Flush() => _source.Flush(); + + private static string _format(string message) + => $"[{DateTime.Now:HH:mm:ss.fff}] {message}"; + } +} diff --git a/GUI/WPF/Helper/ObservableObject.cs b/GUI/WPF/Helper/ObservableObject.cs new file mode 100644 index 0000000..bd2a180 --- /dev/null +++ b/GUI/WPF/Helper/ObservableObject.cs @@ -0,0 +1,57 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace Upsilon.Apps.Passkey.GUI.WPF.Helper +{ + /// + /// Base class providing a minimal implementation + /// for MVVM, without any external dependency. + /// + public abstract class ObservableObject : INotifyPropertyChanged + { + public event PropertyChangedEventHandler? PropertyChanged; + + /// + /// Sets to and raises + /// only when the value actually changes. + /// + /// true if the value has been updated; otherwise false. + protected bool SetProperty(ref T field, T newValue, [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.Default.Equals(field, newValue)) + { + return false; + } + + field = newValue; + OnPropertyChanged(propertyName); + + return true; + } + + /// + /// Sets to and raises + /// for the property plus every name in + /// . + /// + protected bool SetProperty(ref T field, T newValue, IEnumerable alsoNotify, [CallerMemberName] string? propertyName = null) + { + if (!SetProperty(ref field, newValue, propertyName)) + { + return false; + } + + foreach (string name in alsoNotify) + { + OnPropertyChanged(name); + } + + return true; + } + + protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/GUI/WPF/Helper/RelayCommand.cs b/GUI/WPF/Helper/RelayCommand.cs new file mode 100644 index 0000000..bc22d59 --- /dev/null +++ b/GUI/WPF/Helper/RelayCommand.cs @@ -0,0 +1,42 @@ +using System.Windows.Input; + +namespace Upsilon.Apps.Passkey.GUI.WPF.Helper +{ + /// + /// Lightweight implementation that forwards execution + /// to the supplied delegates. Hooks into + /// so WPF automatically re-evaluates . + /// + public sealed class RelayCommand : ICommand + { + private readonly Action _execute; + private readonly Predicate? _canExecute; + + public RelayCommand(Action execute, Func? canExecute = null) + : this(_ => execute(), canExecute is null ? null : _ => canExecute()) + { + ArgumentNullException.ThrowIfNull(execute); + } + + public RelayCommand(Action execute, Predicate? canExecute = null) + { + _execute = execute ?? throw new ArgumentNullException(nameof(execute)); + _canExecute = canExecute; + } + + public event EventHandler? CanExecuteChanged + { + add => CommandManager.RequerySuggested += value; + remove => CommandManager.RequerySuggested -= value; + } + + public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true; + + public void Execute(object? parameter) => _execute(parameter); + + /// + /// Manually requests a re-evaluation of every command's . + /// + public static void RaiseCanExecuteChanged() => CommandManager.InvalidateRequerySuggested(); + } +} diff --git a/GUI/WPF/Helper/SecureStringExtensions.cs b/GUI/WPF/Helper/SecureStringExtensions.cs new file mode 100644 index 0000000..941a49e --- /dev/null +++ b/GUI/WPF/Helper/SecureStringExtensions.cs @@ -0,0 +1,56 @@ +using System.Runtime.InteropServices; +using System.Security; + +namespace Upsilon.Apps.Passkey.GUI.WPF.Helper +{ + /// + /// Helpers that bridge between WPF's (used by + /// PasswordBox.SecurePassword) and the rest of the API surface, while + /// keeping the unmanaged BSTR copy alive only for the duration of the + /// supplied callback and zero-ing it out afterwards. + /// + public static class SecureStringExtensions + { + /// + /// Pins as an unmanaged BSTR, invokes + /// with the resulting (managed) string and then + /// zeros the unmanaged buffer. The managed string returned to the caller + /// still lives in the heap until the GC collects it; callers should keep + /// their use of it as short as possible. + /// + public static T UseAsString(this SecureString value, Func action) + { + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(action); + + IntPtr bstr = IntPtr.Zero; + + try + { + bstr = Marshal.SecureStringToBSTR(value); + string managed = Marshal.PtrToStringBSTR(bstr); + return action(managed); + } + finally + { + if (bstr != IntPtr.Zero) + { + Marshal.ZeroFreeBSTR(bstr); + } + } + } + + /// + /// Same as but with an + /// callback when no value needs to be returned. + /// + public static void UseAsString(this SecureString value, Action action) + { + _ = value.UseAsString(s => + { + action(s); + return 0; + }); + } + } +} diff --git a/GUI/WPF/Helper/SensitiveClipboard.cs b/GUI/WPF/Helper/SensitiveClipboard.cs new file mode 100644 index 0000000..8319584 --- /dev/null +++ b/GUI/WPF/Helper/SensitiveClipboard.cs @@ -0,0 +1,107 @@ +using System.Windows; +using System.Windows.Threading; + +namespace Upsilon.Apps.Passkey.GUI.WPF.Helper +{ + /// + /// Wraps writes to mark the content as sensitive so + /// Windows 10/11 excludes it from Cloud Clipboard and the Win+V history, + /// and schedules an automatic clear after a configurable delay. + /// + /// + /// The format names are documented by Microsoft: applications that opt in + /// to "ExcludeClipboardContentFromMonitoring" or set + /// "CanIncludeInClipboardHistory"/"CanUploadToCloudClipboard" to false ask + /// the OS to keep the payload out of any history/sync surface. + /// + public static class SensitiveClipboard + { + private const string ExcludeFormat = "ExcludeClipboardContentFromMonitoring"; + private const string HistoryFormat = "CanIncludeInClipboardHistory"; + private const string CloudFormat = "CanUploadToCloudClipboard"; + + private static readonly object _autoClearLock = new(); + private static DispatcherTimer? _autoClearTimer; + private static string? _trackedContent; + + /// + /// Copies to the clipboard while flagging it as + /// confidential. If is greater than + /// , the clipboard is cleared after that + /// delay (unless it has been replaced by something else meanwhile). + /// + public static void SetText(string text, TimeSpan? autoClearAfter = null) + { + if (string.IsNullOrEmpty(text)) + { + return; + } + + DataObject data = new(); + data.SetText(text); + data.SetData(ExcludeFormat, true); + data.SetData(HistoryFormat, false); + data.SetData(CloudFormat, false); + + try + { + Clipboard.SetDataObject(data, copy: true); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to write to clipboard"); + return; + } + + if (autoClearAfter is { } delay && delay > TimeSpan.Zero) + { + _scheduleAutoClear(text, delay); + } + } + + /// + /// Clears the clipboard if (and only if) its current text content still + /// matches the value previously written through . + /// + public static void ClearIfStillOwned() + { + lock (_autoClearLock) + { + string? tracked = _trackedContent; + _trackedContent = null; + _autoClearTimer?.Stop(); + _autoClearTimer = null; + + if (tracked is null) return; + + try + { + if (Clipboard.ContainsText() && Clipboard.GetText() == tracked) + { + Clipboard.Clear(); + } + } + catch (Exception ex) + { + Log.Error(ex, "Failed to clear sensitive clipboard content"); + } + } + } + + private static void _scheduleAutoClear(string text, TimeSpan delay) + { + lock (_autoClearLock) + { + _trackedContent = text; + _autoClearTimer?.Stop(); + + _autoClearTimer = new DispatcherTimer(DispatcherPriority.Background) + { + Interval = delay, + }; + _autoClearTimer.Tick += (_, _) => ClearIfStillOwned(); + _autoClearTimer.Start(); + } + } + } +} diff --git a/GUI/WPF/MainWindow.xaml b/GUI/WPF/MainWindow.xaml index c624f38..3368c68 100644 --- a/GUI/WPF/MainWindow.xaml +++ b/GUI/WPF/MainWindow.xaml @@ -11,16 +11,11 @@ Title="{Binding AppTitle}" ResizeMode="CanMinimize" WindowStartupLocation="CenterScreen" - Height="140" Width="500"> - - - - - + Height="150" Width="500"> - - - + + + @@ -30,13 +25,13 @@ @@ -48,23 +43,22 @@ - - diff --git a/GUI/WPF/MainWindow.xaml.cs b/GUI/WPF/MainWindow.xaml.cs index fb430f9..962e363 100644 --- a/GUI/WPF/MainWindow.xaml.cs +++ b/GUI/WPF/MainWindow.xaml.cs @@ -1,12 +1,13 @@ -using Microsoft.Win32; -using System.IO; +using System.IO; using System.Windows; using System.Windows.Input; using System.Windows.Threading; using Upsilon.Apps.Passkey.Core.Models; using Upsilon.Apps.Passkey.GUI.WPF.Helper; +using Upsilon.Apps.Passkey.GUI.WPF.Services; using Upsilon.Apps.Passkey.GUI.WPF.ViewModels; using Upsilon.Apps.Passkey.GUI.WPF.Views; +using Upsilon.Apps.Passkey.Interfaces.Models; namespace Upsilon.Apps.Passkey.GUI.WPF { @@ -18,29 +19,38 @@ public partial class MainWindow : Window private readonly MainViewModel _mainViewModel; private readonly DispatcherTimer _timer; + private static ISessionService Session => AppServices.Session; + public MainWindow() { InitializeComponent(); DataContext = _mainViewModel = new MainViewModel(); + _mainViewModel.ResetRequested += (_, _) => _resetCredentials(); _timer = new() { - Interval = new TimeSpan(0, 0, 5), + Interval = TimeSpan.FromSeconds(5), }; _resetCredentials(); - try + string[] args = Environment.GetCommandLineArgs(); + if (args.Length > 1) { - string[] args = Environment.GetCommandLineArgs(); - string databaseFile = Path.GetFullPath(Environment.GetCommandLineArgs()[1]); - if (File.Exists(databaseFile)) + try + { + string databaseFile = Path.GetFullPath(args[1]); + if (File.Exists(databaseFile)) + { + _mainViewModel.DatabaseFile = databaseFile; + } + } + catch (Exception ex) when (ex is ArgumentException or PathTooLongException or NotSupportedException) { - _mainViewModel.DatabaseFile = databaseFile; + Log.Warn($"Ignored invalid database path from command line: {ex.Message}"); } } - catch { } _username_TB.KeyUp += _credential_TB_KeyUp; _password_PB.KeyUp += _credential_TB_KeyUp; @@ -51,18 +61,7 @@ public MainWindow() private void _timer_Elapsed(object? sender, EventArgs e) { _resetCredentials(); - MainViewModel.Database?.Close(); - MainViewModel.Database = null; - } - - private void _newUser_MenuItem_Click(object sender, RoutedEventArgs e) - { - UserSettingsView.ShowUserSettings(this); - } - - private void _generatePassword_MenuItem_Click(object sender, RoutedEventArgs e) - { - _ = PasswordGenerator.ShowGeneratePasswordDialog(this); + Session.EndSession(); } private void _credential_TB_KeyUp(object sender, KeyEventArgs e) @@ -73,78 +72,20 @@ private void _credential_TB_KeyUp(object sender, KeyEventArgs e) if (sender == _username_TB) { - if (string.IsNullOrEmpty(_username_TB.Text)) - { - _timer.Start(); - return; - } - - if (!File.Exists(_mainViewModel.DatabaseFile)) - { - string filename = MainViewModel.CryptographyCenter.GetHash(_username_TB.Text); - _mainViewModel.DatabaseFile = Path.GetFullPath($"{Path.GetDirectoryName(Environment.ProcessPath)}/raw/{filename}.pku"); - } - - try - { - MainViewModel.Database = Database.Open(MainViewModel.CryptographyCenter, - MainViewModel.SerializationCenter, - MainViewModel.PasswordFactory, - MainViewModel.ClipboardManager, - _mainViewModel.DatabaseFile, - _username_TB.Text); - - MainViewModel.Database.DatabaseClosed += _database_DatabaseClosed; - MainViewModel.Database.AutoSaveDetected += _database_AutoSaveDetected; - } - catch { } - - _mainViewModel.CredentialsLabel = "Password :"; - - _username_TB.Text = string.Empty; - _username_TB.Visibility = Visibility.Collapsed; - - _password_PB.Password = string.Empty; - _password_PB.Visibility = Visibility.Visible; - _ = _password_PB.Focus(); + _submitUsername(); } else { - if (string.IsNullOrEmpty(_password_PB.Password)) - { - _timer.Start(); - return; - } - - if (MainViewModel.Database is not null) - { - _ = MainViewModel.Database.Login(_password_PB.Password); - - if (MainViewModel.Database.User is not null) - { - Hide(); - _resetCredentials(); - - if (!UserServicesView.ShowUser(this)) - { - Close(); - } - else - { - _resetCredentials(); - } - } - } + _submitPassword(); } - _password_PB.Password = string.Empty; + _password_PB.Clear(); _timer.Start(); } else if (e.Key == Key.Escape) { _resetCredentials(); - MainViewModel.Database?.Close(); - MainViewModel.Database = null; + Session.EndSession(); } else { @@ -153,11 +94,97 @@ private void _credential_TB_KeyUp(object sender, KeyEventArgs e) } } + private void _submitUsername() + { + if (string.IsNullOrEmpty(_username_TB.Text)) + { + _timer.Start(); + return; + } + + if (!File.Exists(_mainViewModel.DatabaseFile)) + { + string filename = AppServices.Cryptography.GetHash(_username_TB.Text); + _mainViewModel.DatabaseFile = Path.GetFullPath($"{Path.GetDirectoryName(Environment.ProcessPath)}/raw/{filename}.pku"); + } + + try + { + IDatabase database = Database.Open(AppServices.Cryptography, + AppServices.Serialization, + AppServices.PasswordFactory, + AppServices.Clipboard, + _mainViewModel.DatabaseFile, + _username_TB.Text); + + database.DatabaseClosed += _database_DatabaseClosed; + database.AutoSaveDetected += _database_AutoSaveDetected; + Session.StartSession(database); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to open database"); + } + + _mainViewModel.CredentialsLabel = "Password :"; + + _username_TB.Text = string.Empty; + _username_TB.Visibility = Visibility.Collapsed; + + _password_PB.Clear(); + _password_PB.Visibility = Visibility.Visible; + _ = _password_PB.Focus(); + } + + private void _submitPassword() + { + if (_password_PB.SecurePassword.Length == 0) + { + _timer.Start(); + return; + } + + if (Session.Database is null) + { + return; + } + + _password_PB.SecurePassword.UseAsString(passkey => + { + _ = Session.Database.Login(passkey); + }); + + // Erase the PasswordBox buffer right after submitting so the secret + // is not kept alive longer than necessary. + _password_PB.Clear(); + + if (Session.Database.User is null) + { + return; + } + + Hide(); + _resetCredentials(); + + if (!UserServicesView.ShowUser(this)) + { + Close(); + } + else + { + _resetCredentials(); + } + } + private void _database_AutoSaveDetected(object? sender, Interfaces.Events.AutoSaveDetectedEventArgs e) { Hide(); - MessageBoxResult result = MessageBox.Show("Unsaved changes have been detected.\nClick Yes to apply these changes.\nClick No to discard them.\nClick Cancel to ignore and keep the save file.", "Autosave detected", MessageBoxButton.YesNoCancel, MessageBoxImage.Question); + MessageBoxResult result = AppServices.Dialogs.Confirm( + "Unsaved changes have been detected.\nClick Yes to apply these changes.\nClick No to discard them.\nClick Cancel to ignore and keep the save file.", + "Autosave detected", + MessageBoxButton.YesNoCancel, + MessageBoxImage.Question); e.MergeBehavior = result switch { @@ -169,16 +196,17 @@ private void _database_AutoSaveDetected(object? sender, Interfaces.Events.AutoSa private void _database_DatabaseClosed(object? sender, Interfaces.Events.LogoutEventArgs e) { - try + if (Dispatcher.HasShutdownStarted || Dispatcher.HasShutdownFinished) { - Dispatcher.Invoke(() => - { - _resetCredentials(); - MainViewModel.Database = null; - Show(); - }); + return; } - catch { } + + _ = Dispatcher.BeginInvoke(() => + { + _resetCredentials(); + Session.EndSession(); + Show(); + }); } private void _resetCredentials() @@ -190,26 +218,10 @@ private void _resetCredentials() _username_TB.Visibility = Visibility.Visible; _ = _username_TB.Focus(); - _password_PB.Password = string.Empty; + _password_PB.Clear(); _password_PB.Visibility = Visibility.Collapsed; _timer.Stop(); } - - private void _openDatabase_MenuItem_Click(object sender, RoutedEventArgs e) - { - OpenFileDialog dialog = new() - { - Title = "Open user database file", - Filter = "Passkey user database file|*.pku", - }; - - if (!(dialog.ShowDialog() ?? false)) return; - - _resetCredentials(); - MainViewModel.Database?.Close(); - MainViewModel.Database = null; - _mainViewModel.DatabaseFile = dialog.FileName; - } } -} \ No newline at end of file +} diff --git a/GUI/WPF/Services/AppServices.cs b/GUI/WPF/Services/AppServices.cs new file mode 100644 index 0000000..37d814b --- /dev/null +++ b/GUI/WPF/Services/AppServices.cs @@ -0,0 +1,30 @@ +using Upsilon.Apps.Passkey.Core.Utils; +using Upsilon.Apps.Passkey.GUI.WPF.OSSpecific; +using Upsilon.Apps.Passkey.Interfaces.Utils; + +namespace Upsilon.Apps.Passkey.GUI.WPF.Services +{ + /// + /// Minimalist service locator. Provides single instances of every cross-cutting + /// service so view-models can grab them without taking on a third-party DI + /// container. The factories below are intentionally inline because there is + /// only one composition root (the WPF process) and no extra dependency is + /// allowed. + /// + public static class AppServices + { + public static IDialogService Dialogs { get; } = new DialogService(); + + public static ISessionService Session { get; } = new SessionService(); + + public static INavigationService Navigation { get; } = new NavigationService(); + + public static ICryptographyCenter Cryptography { get; } = new CryptographyCenter(); + + public static ISerializationCenter Serialization { get; } = new JsonSerializationCenter(); + + public static IPasswordFactory PasswordFactory { get; } = new PasswordFactory(); + + public static IClipboardManager Clipboard { get; } = new ClipboardManager(); + } +} diff --git a/GUI/WPF/Services/DialogService.cs b/GUI/WPF/Services/DialogService.cs new file mode 100644 index 0000000..2efdf72 --- /dev/null +++ b/GUI/WPF/Services/DialogService.cs @@ -0,0 +1,117 @@ +using Microsoft.Win32; +using System.Windows; + +namespace Upsilon.Apps.Passkey.GUI.WPF.Services +{ + internal sealed class DialogService : IDialogService + { + private readonly Dictionary _singletons = []; + + public bool? ShowDialog(TWindow window) where TWindow : Window + { + ArgumentNullException.ThrowIfNull(window); + + window.Owner = _resolveOwner(); + return window.ShowDialog(); + } + + public TWindow ShowSingleton(Func factory, Action? configure = null) where TWindow : Window + { + ArgumentNullException.ThrowIfNull(factory); + + if (_singletons.TryGetValue(typeof(TWindow), out Window? existing) + && existing is TWindow loaded + && loaded.IsLoaded) + { + configure?.Invoke(loaded); + _ = loaded.Activate(); + return loaded; + } + + TWindow window = factory(); + window.Owner = _resolveOwner(); + + configure?.Invoke(window); + + window.Closed += (_, _) => + { + if (_singletons.TryGetValue(typeof(TWindow), out Window? tracked) && ReferenceEquals(tracked, window)) + { + _ = _singletons.Remove(typeof(TWindow)); + } + }; + + _singletons[typeof(TWindow)] = window; + window.Show(); + + return window; + } + + public TWindow? GetSingleton() where TWindow : Window + { + return _singletons.TryGetValue(typeof(TWindow), out Window? window) ? window as TWindow : null; + } + + public void Close() where TWindow : Window + { + if (_singletons.TryGetValue(typeof(TWindow), out Window? window)) + { + _ = _singletons.Remove(typeof(TWindow)); + window.Close(); + } + } + + public MessageBoxResult Confirm(string text, string title, MessageBoxButton button = MessageBoxButton.YesNo, MessageBoxImage image = MessageBoxImage.Question) + { + Window? owner = _resolveOwner(); + return owner is null + ? MessageBox.Show(text, title, button, image) + : MessageBox.Show(owner, text, title, button, image); + } + + public void Info(string text, string title) + => _ = Confirm(text, title, MessageBoxButton.OK, MessageBoxImage.Information); + + public void Warn(string text, string title) + => _ = Confirm(text, title, MessageBoxButton.OK, MessageBoxImage.Warning); + + public string? PickOpenFile(string filter, string title) + { + OpenFileDialog dialog = new() + { + Title = title, + Filter = filter, + }; + + return (dialog.ShowDialog() ?? false) ? dialog.FileName : null; + } + + public string? PickSaveFile(string filter, string title, string? defaultFileName = null) + { + SaveFileDialog dialog = new() + { + Title = title, + Filter = filter, + FileName = defaultFileName ?? string.Empty, + }; + + return (dialog.ShowDialog() ?? false) ? dialog.FileName : null; + } + + private static Window? _resolveOwner() + { + Window? application = Application.Current?.MainWindow; + if (application is null) return null; + + foreach (Window window in Application.Current!.Windows) + { + if (window.IsActive) + { + return window; + } + } + + return application; + } + } +} diff --git a/GUI/WPF/Services/IDialogService.cs b/GUI/WPF/Services/IDialogService.cs new file mode 100644 index 0000000..7a6f520 --- /dev/null +++ b/GUI/WPF/Services/IDialogService.cs @@ -0,0 +1,45 @@ +using System.Windows; + +namespace Upsilon.Apps.Passkey.GUI.WPF.Services +{ + /// + /// Abstracts every window/message-box/file-dialog open call so view-models + /// can stay free of WPF dependencies and remain testable. + /// + public interface IDialogService + { + /// + /// Shows the supplied modally as a child of the + /// currently active window. + /// + bool? ShowDialog(TWindow window) where TWindow : Window; + + /// + /// Shows a window of type non-modally. If a + /// window of the same type is already open and loaded, it is activated + /// (after running ) instead of being recreated. + /// + TWindow ShowSingleton(Func factory, Action? configure = null) where TWindow : Window; + + /// Returns the singleton window of type currently being tracked, if any. + TWindow? GetSingleton() where TWindow : Window; + + /// Closes the singleton window of type if any. + void Close() where TWindow : Window; + + /// Shows a confirmation dialog with the given prompt. + MessageBoxResult Confirm(string text, string title, MessageBoxButton button = MessageBoxButton.YesNo, MessageBoxImage image = MessageBoxImage.Question); + + /// Shows an information dialog with the given message. + void Info(string text, string title); + + /// Shows a warning dialog with the given message. + void Warn(string text, string title); + + /// Asks the user to pick an existing file. Returns null when cancelled. + string? PickOpenFile(string filter, string title); + + /// Asks the user to pick a destination file. Returns null when cancelled. + string? PickSaveFile(string filter, string title, string? defaultFileName = null); + } +} diff --git a/GUI/WPF/Services/INavigationService.cs b/GUI/WPF/Services/INavigationService.cs new file mode 100644 index 0000000..7aabb26 --- /dev/null +++ b/GUI/WPF/Services/INavigationService.cs @@ -0,0 +1,16 @@ +namespace Upsilon.Apps.Passkey.GUI.WPF.Services +{ + /// + /// Decouples the navigation requests (such as "go to this item") from the + /// view-models that emit them. Replaces the static MainViewModel.GoToItem + /// delegate. + /// + public interface INavigationService + { + /// Raised when a caller asks to navigate to a specific item. + event EventHandler? ItemRequested; + + /// Asks any subscriber to navigate to . + void RequestItem(string itemId); + } +} diff --git a/GUI/WPF/Services/ISessionService.cs b/GUI/WPF/Services/ISessionService.cs new file mode 100644 index 0000000..c9e097d --- /dev/null +++ b/GUI/WPF/Services/ISessionService.cs @@ -0,0 +1,32 @@ +using Upsilon.Apps.Passkey.Interfaces.Models; + +namespace Upsilon.Apps.Passkey.GUI.WPF.Services +{ + /// + /// Tracks the currently open for the whole UI. + /// Replaces the static MainViewModel.Database field so consumers can + /// be unit-tested and react to lifecycle changes through events. + /// + public interface ISessionService + { + /// The active database, or null when nobody is logged in. + IDatabase? Database { get; } + + /// The active user, or null when no database is loaded or the user is not logged in. + IUser? User { get; } + + /// Raised whenever or changes. + event EventHandler? SessionChanged; + + /// + /// Registers as the current session. Any existing + /// session is closed first. + /// + void StartSession(IDatabase database); + + /// + /// Closes the current session, if any. + /// + void EndSession(); + } +} diff --git a/GUI/WPF/Services/NavigationService.cs b/GUI/WPF/Services/NavigationService.cs new file mode 100644 index 0000000..875babf --- /dev/null +++ b/GUI/WPF/Services/NavigationService.cs @@ -0,0 +1,12 @@ +namespace Upsilon.Apps.Passkey.GUI.WPF.Services +{ + internal sealed class NavigationService : INavigationService + { + public event EventHandler? ItemRequested; + + public void RequestItem(string itemId) + { + ItemRequested?.Invoke(this, itemId); + } + } +} diff --git a/GUI/WPF/Services/SessionService.cs b/GUI/WPF/Services/SessionService.cs new file mode 100644 index 0000000..d4dc5ff --- /dev/null +++ b/GUI/WPF/Services/SessionService.cs @@ -0,0 +1,45 @@ +using Upsilon.Apps.Passkey.GUI.WPF.Helper; +using Upsilon.Apps.Passkey.Interfaces.Models; + +namespace Upsilon.Apps.Passkey.GUI.WPF.Services +{ + internal sealed class SessionService : ISessionService + { + public IDatabase? Database { get; private set; } + + public IUser? User => Database?.User; + + public event EventHandler? SessionChanged; + + public void StartSession(IDatabase database) + { + ArgumentNullException.ThrowIfNull(database); + + EndSession(); + + Database = database; + Log.Info("Session started."); + SessionChanged?.Invoke(this, EventArgs.Empty); + } + + public void EndSession() + { + SensitiveClipboard.ClearIfStillOwned(); + + if (Database is null) return; + + try + { + Database.Close(); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to close database cleanly"); + } + + Database = null; + Log.Info("Session ended."); + SessionChanged?.Invoke(this, EventArgs.Empty); + } + } +} diff --git a/GUI/WPF/Themes/DarkMode.cs b/GUI/WPF/Themes/DarkMode.cs index fd5f8f0..8b1dade 100644 --- a/GUI/WPF/Themes/DarkMode.cs +++ b/GUI/WPF/Themes/DarkMode.cs @@ -7,9 +7,14 @@ namespace Upsilon.Apps.Passkey.GUI.WPF.Themes { public static class DarkMode { - public static Brush UnchangedBrush1 => new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x1E)); - public static Brush UnchangedBrush2 => new SolidColorBrush(Color.FromRgb(0x2D, 0x2D, 0x30)); - public static Brush ChangedBrush => new SolidColorBrush(Color.FromRgb(0x60, 0x60, 0x60)); + /// Background brush used for the main window/panel surfaces (#1E1E1E). + public static readonly Brush UnchangedBrush1 = _freeze(Color.FromRgb(0x1E, 0x1E, 0x1E)); + + /// Background brush used for inputs that have not been modified (#2D2D30). + public static readonly Brush UnchangedBrush2 = _freeze(Color.FromRgb(0x2D, 0x2D, 0x30)); + + /// Background brush used to highlight inputs whose value has been modified (#606060). + public static readonly Brush ChangedBrush = _freeze(Color.FromRgb(0x60, 0x60, 0x60)); public static void SetDarkMode(Window window) { @@ -25,6 +30,13 @@ public static void SetDarkMode(Window window) _ = DwmSetWindowAttribute(hwnd, attribute, ref useImmersiveDarkMode, sizeof(int)); } + private static Brush _freeze(Color color) + { + SolidColorBrush brush = new(color); + brush.Freeze(); + return brush; + } + [DllImport("dwmapi.dll", PreserveSig = true)] private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int attrValue, int attrSize); } diff --git a/GUI/WPF/Themes/DarkTheme.xaml b/GUI/WPF/Themes/DarkTheme.xaml index 644e73e..4ff2f6b 100644 --- a/GUI/WPF/Themes/DarkTheme.xaml +++ b/GUI/WPF/Themes/DarkTheme.xaml @@ -7,8 +7,25 @@ + + + + + + + + + + + + + + @@ -17,8 +34,36 @@ + + + + + + + + + +