Public Access
- The Windows CNG key and the macOS keychain tag were one name for every profile, so signing out of the nightly deleted the key the release build's device unlock was sealed to. DeviceKeyNames keeps the existing name for the default profile and gives every other profile its own; sign-out deletes only its own key, and a profile sealed before this keeps reading the shared one. - DevicePinWindow named two palette resources that do not exist and loaded itself with AvaloniaXamlLoader.Load, which never assigns the named fields, so it threw on construction and no PIN prompt was ever shown. A layout test now builds it against the real palette.
207 lines
7.1 KiB
C#
207 lines
7.1 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Asks for the PIN that unseals this machine's device key.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Raised by <see cref="LinuxDeviceKeyStore"/> through the delegate <c>App.axaml.cs</c> supplies, and by
|
|
/// nothing else. See the markup for why this is a window rather than a card, and why it is plain.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Cancelling is an ordinary answer, not an error.</b> 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 <c>IDeviceKeyStore.TryLoadAsync</c> documents for a declined fingerprint.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed partial class DevicePinWindow : Window
|
|
{
|
|
/// <summary>
|
|
/// The shortest PIN this will accept, and the reason it is this short rather than longer.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
private const int ShortestPin = 4;
|
|
|
|
private TaskCompletionSource<string?>? answer;
|
|
|
|
/// <summary>Creates the window. Avalonia's designer needs this; nothing else should call it.</summary>
|
|
public DevicePinWindow()
|
|
{
|
|
// InitializeComponent, not AvaloniaXamlLoader.Load: only the generated method assigns the x:Name
|
|
// fields below, and with Load the first of them was null — so this window threw on construction and
|
|
// no Linux PIN prompt was ever shown.
|
|
InitializeComponent();
|
|
|
|
Accept.Click += OnAccept;
|
|
Cancel.Click += OnCancel;
|
|
KeyDown += OnKeyDown;
|
|
Pin.KeyDown += OnKeyDown;
|
|
Confirm.KeyDown += OnKeyDown;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shows the prompt over the main window and answers with what was typed, or null.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Marshalled onto the UI thread here rather than at the call site.</b> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="owner">The window to sit over.</param>
|
|
/// <param name="purpose">Whether a PIN is being chosen or entered.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
public static ValueTask<string?> AskAsync(
|
|
Func<Window?> owner,
|
|
DevicePinPurpose purpose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(owner);
|
|
|
|
return new ValueTask<string?>(
|
|
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<string?> ShowFor(Window parent)
|
|
{
|
|
answer = new TaskCompletionSource<string?>(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);
|
|
|
|
/// <summary>
|
|
/// Checks what was typed and either answers with it or says what is wrong.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Both checks are about a PIN being <em>chosen</em>. 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.
|
|
/// </remarks>
|
|
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();
|
|
}
|
|
}
|