Public Access
The last of ADR 0007's three pieces, and it does not implement what that ADR originally decided — because writing it exposed a flaw in the decision. The ADR said "a Windows Hello gesture gating a protected blob". That does not deliver what the rest of the document claims for it: a gate inside the process is not a gate. A store that showed a prompt and then read a DPAPI blob would be bypassed by malware that skipped the prompt, read the file and called CryptUnprotectData itself — which is exactly the attacker the whole decision was made against, and exactly the reason DPAPI alone was rejected. The presence requirement has to be a condition of using the key, enforced below the application, or it is decoration. So the device key is encrypted to an RSA key created in the Microsoft Platform Crypto Provider — the TPM — under CngUIProtectionLevels.ProtectKey. Windows requires consent to use that key, so the prompt is not something this code can be talked out of showing. Malware can ask for the key; it cannot answer the dialog. That is strictly stronger than the ADR described, and most of what option D was being saved for: the wrapping key genuinely never leaves hardware. The X25519 device key still lands in memory to open the wrap, because DSH1 fixes that wrap at a curve the TPM cannot do — the remaining gap, and now a smaller step than it was. CngKey is in-box, so this needed no WinRT projection and no Windows target framework. Which is worth stating plainly because the opposite was planned: the piece was scoped as "where the Windows TFM lands", and it turned out a platform guard on one class was enough. Client.App and its two test projects stay on net10.0. Two things were measured on real hardware rather than assumed, and the second changed the shape of the work. The platform provider works here and holds an RSA key — confirmed by creating and deleting one before writing anything that depended on it. And ProtectKey prompts at key *creation*, not only at use. The comment in the first draft of this file said the opposite, with a confident explanation: sealing uses only the public half, so it should be silent. It is not. CngKey.Create blocks on a dialog, because the policy means "protect this key with a PIN" and Windows asks the user to set that up there and then. Found by writing tests around save and forget and watching the suite hang for ten minutes waiting for somebody to type one. That has two consequences worth knowing before touching this file. SaveAsync is user-facing code — it belongs on a UI thread, behind a button somebody pressed, never on a background pass. And almost nothing in the store can be covered automatically: two tests remain, availability and the empty-blob case, both of which provably reach no dialog. Disabling the UI policy to make the rest testable would remove the one property worth having. The interface offers two things and hides both where they cannot work. "Use Windows Hello" appears on the unlock screen only when this machine has a cached wrap and a keystore still willing to release the key; "Use Windows Hello here" appears in the account bar only when the machine can keep a key and has not already registered one, so it is spent once used. Absent rather than disabled, in both cases: a greyed-out button on a machine that never had a TPM reads as something broken, and the passphrase box beside it is not a fallback — it is the ordinary way in. Both unlock paths now share AdoptAsync rather than each opening the known-host store, building the vault and starting auto-sync. The ordering in there is load-bearing and a second copy would be a second chance to get it wrong. The shell's tests drive a fake keystore. Not for speed: the real one prompts on every save and load, so a suite using it would block forever. What the shell has to get right is which buttons appear and what happens when one is pressed, and a fake answers exactly that. It is shared from Client.Session.Tests by source link rather than reimplemented. 882 tests green, 6 of them new. Zero warnings, dotnet format clean. Not verified, and not verifiable here: the dialogs. Whether the consent prompt appears at the right moments, reads sensibly, and returns to a usable window when declined needs the application run by a person on a machine with a TPM. That is the remaining half of outstanding item #7, and it is now the only thing between this feature and being finished.
130 lines
4.9 KiB
C#
130 lines
4.9 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls.ApplicationLifetimes;
|
|
using Avalonia.Markup.Xaml;
|
|
using DodoSSH.Client.App.Terminal;
|
|
using DodoSSH.Client.App.ViewModels;
|
|
using DodoSSH.Client.App.Views;
|
|
using DodoSSH.Client.Auth;
|
|
using DodoSSH.Client.Session;
|
|
using DodoSSH.Client.Ssh;
|
|
using DodoSSH.Client.Storage;
|
|
using DodoSSH.Client.Terminal;
|
|
|
|
namespace DodoSSH.Client.App;
|
|
|
|
/// <summary>
|
|
/// The Avalonia application.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Named <c>DodoSshApp</c> rather than the conventional <c>App</c> only because the assembly's root
|
|
/// namespace already ends in <c>App</c>, and a type whose name matches its namespace forces every
|
|
/// ambiguous reference to be fully qualified.
|
|
/// </remarks>
|
|
internal sealed partial class DodoSshApp : Application
|
|
{
|
|
/// <inheritdoc />
|
|
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
|
|
|
/// <inheritdoc />
|
|
public override void OnFrameworkInitializationCompleted()
|
|
{
|
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
|
{
|
|
Compose(desktop);
|
|
}
|
|
|
|
base.OnFrameworkInitializationCompleted();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Composed by hand rather than through a container. The graph is a handful of objects deep and an
|
|
/// indirection to read through would buy nothing at this size.
|
|
/// </para>
|
|
/// <para>
|
|
/// Everything disposable is a local captured by the closures below rather than a field, because an
|
|
/// Avalonia <c>Application</c> has no disposal hook of its own and a type that owned them would have
|
|
/// nowhere honest to release them.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static void Compose(IClassicDesktopStyleApplicationLifetime desktop)
|
|
{
|
|
var paths = ClientPaths.Default;
|
|
var caches = ClientCacheFactory.ForFile(paths.CacheFile);
|
|
|
|
// Known hosts live in the vault, so trust survives a restart and follows the user to every device.
|
|
// Composed here, once, because the connection factory below needs it now and outlives every unlock;
|
|
// the vault behind it is attached and detached as one is opened and locked. See VaultKnownHostStore
|
|
// for why the handshake is answered from a snapshot rather than by reading the vault per lookup.
|
|
var knownHosts = new VaultKnownHostStore();
|
|
|
|
var workspace = new TerminalWorkspace(
|
|
new AvaloniaTerminalAssetProvider(),
|
|
new SshNetConnectionFactory(knownHosts),
|
|
TimeProvider.System);
|
|
|
|
workspace.Start();
|
|
|
|
var browser = new SystemBrowserLauncher();
|
|
|
|
// Chosen once, here, because it is a property of the machine and not of any session. A computer with
|
|
// a usable TPM gets the store that keeps a device key behind a Windows consent prompt; anything else
|
|
// gets one that reports itself unavailable, so unlock keeps asking for the passphrase. See ADR 0007.
|
|
var deviceKeys = DeviceKeyStores.ForThisMachine(paths);
|
|
|
|
var viewModel = new MainWindowViewModel(
|
|
paths,
|
|
caches,
|
|
workspace,
|
|
knownHosts,
|
|
deviceKeys,
|
|
async (url, cancellationToken) => await ServerConnection
|
|
.SignInAsync(url, browser, TimeProvider.System, cancellationToken)
|
|
.ConfigureAwait(false),
|
|
TimeProvider.System);
|
|
|
|
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
|
|
|
// Started rather than awaited: the framework's initialisation must not block on a schema
|
|
// migration. The view model shows its own progress and handles its own failures, which is why
|
|
// discarding the task here is safe rather than merely convenient.
|
|
_ = viewModel.StartAsync(CancellationToken.None);
|
|
|
|
WireShutdown(desktop, viewModel, workspace, caches);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Shutdown is deferred rather than blocked on. Sessions hold SSH connections and a listening socket, and
|
|
/// blocking the UI thread on their disposal is how an application comes to take several seconds to close —
|
|
/// or deadlocks, if any of that disposal needs the UI thread.
|
|
/// </remarks>
|
|
private static void WireShutdown(
|
|
IClassicDesktopStyleApplicationLifetime desktop,
|
|
MainWindowViewModel viewModel,
|
|
TerminalWorkspace workspace,
|
|
ClientCacheFactory caches)
|
|
{
|
|
var shuttingDown = false;
|
|
|
|
desktop.ShutdownRequested += async (_, e) =>
|
|
{
|
|
if (shuttingDown)
|
|
{
|
|
return;
|
|
}
|
|
|
|
shuttingDown = true;
|
|
e.Cancel = true;
|
|
|
|
// The view model first: it holds the vault session, and disposing that is what zeroes the
|
|
// identity keys, the vault keys and the cache key.
|
|
await viewModel.DisposeAsync().ConfigureAwait(true);
|
|
await workspace.DisposeAsync().ConfigureAwait(true);
|
|
|
|
caches.Dispose();
|
|
|
|
desktop.Shutdown();
|
|
};
|
|
}
|
|
}
|