using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Markup.Xaml; using Avalonia.Threading; using DodoSSH.Client.App.Platform; namespace DodoSSH.Client.App.Views; /// /// Asks for the PIN that unseals this machine's device key. /// /// /// /// Raised by through the delegate App.axaml.cs supplies, and by /// nothing else. See the markup for why this is a window rather than a card, and why it is plain. /// /// /// Cancelling is an ordinary answer, not an error. Escape, the close button and CANCEL all resolve /// to null, which the store reads as "no PIN" and the unlock path answers with the passphrase screen. That /// is the same contract IDeviceKeyStore.TryLoadAsync documents for a declined fingerprint. /// /// public sealed partial class DevicePinWindow : Window { /// /// The shortest PIN this will accept, and the reason it is this short rather than longer. /// /// /// Four, because the TPM counts wrong answers and stops — which is exactly the property that makes a /// short secret safe here and does not exist for anything kept in a file. A PIN long enough to be safe /// without hardware counting would be a second passphrase, and somebody asked for a second passphrase /// reuses the first. /// private const int ShortestPin = 4; private TaskCompletionSource? answer; /// Creates the window. Avalonia's designer needs this; nothing else should call it. public DevicePinWindow() { AvaloniaXamlLoader.Load(this); Accept.Click += OnAccept; Cancel.Click += OnCancel; KeyDown += OnKeyDown; Pin.KeyDown += OnKeyDown; Confirm.KeyDown += OnKeyDown; } /// /// Shows the prompt over the main window and answers with what was typed, or null. /// /// /// /// Marshalled onto the UI thread here rather than at the call site. The store is called from /// wherever the unlock happens to be running — a background task on launch, a command handler on a /// button — and none of those should have to know that this ends in a window. Avalonia throws rather /// than misbehaving if it is wrong, which would turn a device unlock into a crash. /// /// /// A missing owner answers null rather than showing an ownerless window. It means the main window is /// gone or not up yet, and a modal dialog with nothing behind it is a prompt the user cannot place — /// which is precisely the shape of the thing this application must not train people to accept. /// /// /// The window to sit over. /// Whether a PIN is being chosen or entered. /// Cancellation token. public static ValueTask AskAsync( Func owner, DevicePinPurpose purpose, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(owner); return new ValueTask( Dispatcher.UIThread.InvokeAsync( async () => { if (owner() is not { } parent) { return null; } var window = new DevicePinWindow(); window.Prepare(purpose); using var registration = cancellationToken.Register(window.Close); return await window.ShowFor(parent).ConfigureAwait(true); })); } private void Prepare(DevicePinPurpose purpose) { var choosing = purpose == DevicePinPurpose.Choosing; Heading.Text = choosing ? "Choose a PIN for this computer" : "Enter your DodoSSH PIN"; Explanation.Text = choosing ? "Your device key is sealed into this computer's TPM. The PIN is checked by the TPM itself, " + "which stops answering after a few wrong tries — so a short one is enough, and it only " + "ever works on this computer." : "This unlocks the key sealed into this computer's TPM. Cancel to use your passphrase " + "instead."; Confirm.IsVisible = choosing; Accept.Content = choosing ? "SET PIN" : "UNLOCK"; } private async Task ShowFor(Window parent) { answer = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); // Resolves the pending answer however the window goes away — the close button, Escape, or the // cancellation registration above. Without it a closed window leaves an await that never returns, // and the unlock path hangs rather than falling back to the passphrase. Closed += (_, _) => answer.TrySetResult(null); Pin.Focus(); await ShowDialog(parent).ConfigureAwait(true); return await answer.Task.ConfigureAwait(true); } private void OnKeyDown(object? sender, KeyEventArgs e) { switch (e.Key) { case Key.Escape: Complete(null); e.Handled = true; break; case Key.Enter: Submit(); e.Handled = true; break; default: break; } } private void OnAccept(object? sender, RoutedEventArgs e) => Submit(); private void OnCancel(object? sender, RoutedEventArgs e) => Complete(null); /// /// Checks what was typed and either answers with it or says what is wrong. /// /// /// Both checks are about a PIN being chosen. Nothing validates a PIN being entered, because /// the only thing that can judge that is the TPM — refusing a four-character entry here because the /// stored one was six would be this process deciding something it has no way to know. /// private void Submit() { var pin = Pin.Text ?? string.Empty; if (!Confirm.IsVisible) { if (pin.Length > 0) { Complete(pin); } return; } if (pin.Length < ShortestPin) { Show($"A PIN needs at least {ShortestPin} characters."); return; } if (!string.Equals(pin, Confirm.Text ?? string.Empty, StringComparison.Ordinal)) { Show("Those two do not match."); return; } Complete(pin); } private void Show(string problem) { Problem.Text = problem; Problem.IsVisible = true; } private void Complete(string? pin) { answer?.TrySetResult(pin); Close(); } }