Keep the device key in the TPM, behind a consent Windows enforces

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.
This commit is contained in:
2026-07-30 15:17:30 +02:00
parent 1faea42b94
commit 573f5d5668
12 changed files with 699 additions and 88 deletions
+20 -3
View File
@@ -67,11 +67,17 @@ internal sealed partial class DodoSshApp : Application
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),
@@ -84,11 +90,22 @@ internal sealed partial class DodoSshApp : Application
// 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;
// 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.
desktop.ShutdownRequested += async (_, e) =>
{
if (shuttingDown)
@@ -68,6 +68,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
private readonly VaultKnownHostStore knownHosts;
/// <remarks>
/// Whatever this machine can keep a device key in, chosen once at composition. An interface because the
/// answer is a platform decision — see ADR 0007 — and because a machine with no TPM gets a store that
/// reports itself unavailable rather than a null this state machine would have to check for.
/// </remarks>
private readonly IDeviceKeyStore deviceKeys;
private readonly SignInHandler signIn;
private readonly TimeProvider clock;
private readonly Argon2Profile? passphraseProfile;
@@ -91,6 +98,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
ClientCacheFactory caches,
TerminalWorkspace workspace,
VaultKnownHostStore knownHosts,
IDeviceKeyStore deviceKeys,
SignInHandler signIn,
TimeProvider clock,
Argon2Profile? passphraseProfile = null)
@@ -99,6 +107,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
this.caches = caches;
this.workspace = workspace;
this.knownHosts = knownHosts;
this.deviceKeys = deviceKeys;
this.signIn = signIn;
this.clock = clock;
this.passphraseProfile = passphraseProfile;
@@ -113,6 +122,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[ObservableProperty]
private bool isBusy;
/// <summary>Whether the unlock screen should offer a gesture instead of the passphrase.</summary>
[ObservableProperty]
private bool canUnlockWithDevice;
/// <summary>Whether an unlocked vault should offer to register this machine.</summary>
[ObservableProperty]
private bool canRegisterDevice;
/// <remarks>
/// The address <c>dotnet run --project src/DodoSSH.Api</c> actually serves, so the first launch after
/// a clone works without the user having to know a port. This was <c>https://localhost:7217</c>, which
@@ -219,6 +236,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
ServerUrl = profile.ServerUrl;
State = ShellState.Locked;
StatusMessage = $"Enrolled against {profile.ServerUrl}.";
// Both halves have to hold: a wrap in the cache, and a machine still willing to hand the key
// back. Offering the button without the second would prompt for a key that is not there; without
// the first it would prompt for a wrap that is not there. Neither failure is one a user could
// make sense of, so the button simply does not appear.
CanUnlockWithDevice = profile.DeviceWrappedPrivateKey is not null
&& await deviceKeys.IsAvailableAsync(cancellationToken).ConfigureAwait(true);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
@@ -362,34 +386,119 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
Passphrase = string.Empty;
// Before the vault view model, so the first connection after an unlock already knows which
// host keys this user has approved. Reading them is one listing; doing it here rather than
// lazily is what keeps it off the SSH handshake thread.
try
{
await knownHosts.OpenAsync(outcome.Session!, cancellationToken).ConfigureAwait(true);
}
catch
{
// Nothing owns the session yet, so nothing else would ever dispose it — and an
// undisposed session is vault keys left in memory for the life of the process, which is
// precisely what unlocking must be able to undo.
await outcome.Session!.DisposeAsync().ConfigureAwait(true);
throw;
}
Vault = new VaultViewModel(outcome.Session!, workspace, knownHosts, () => connection);
State = ShellState.Unlocked;
await Vault.LoadAsync(cancellationToken).ConfigureAwait(true);
// After the first load, so the list is on screen before anything talks to a server. The
// loop is started from the UI thread deliberately: every pass resumes here, which is what
// keeps the observable collections single-threaded.
Vault.StartAutoSync();
await AdoptAsync(outcome.Session!, cancellationToken).ConfigureAwait(true);
}).ConfigureAwait(true);
}
/// <summary>Opens the vault with this machine's device key instead of the passphrase.</summary>
/// <remarks>
/// No <c>Task.Run</c>, unlike the passphrase path: there is no Argon2 to pay for here, and the work that
/// does block is a Windows consent dialog which belongs on the UI thread anyway.
/// </remarks>
[RelayCommand]
private async Task UnlockWithDeviceAsync(CancellationToken cancellationToken)
{
await RunAsync(
"Waiting for Windows…",
async () =>
{
var outcome = await Opener()
.UnlockWithDeviceAsync(deviceKeys, cancellationToken)
.ConfigureAwait(true);
StatusMessage = outcome.Message;
if (!outcome.IsUnlocked)
{
// A declined gesture leaves the passphrase box exactly where it was, which is the whole
// fallback: the user types instead. Nothing about the screen changes but the message.
return;
}
await AdoptAsync(outcome.Session!, cancellationToken).ConfigureAwait(true);
}).ConfigureAwait(true);
}
/// <summary>
/// Registers this machine so a later launch can unlock with a gesture.
/// </summary>
/// <remarks>
/// Needs a network, because the wrap has to reach the server — a wrap that exists only here would be
/// lost with the cache file and could never be revoked. Needs an unlocked vault too, because only an
/// open session can seal the bundle.
/// </remarks>
[RelayCommand]
private async Task RegisterDeviceAsync(CancellationToken cancellationToken)
{
if (Vault is not { } vault || connection is null)
{
StatusMessage = "Sign in first: registering this machine has to reach the server.";
return;
}
await RunAsync(
"Waiting for Windows…",
async () =>
{
var name = Environment.MachineName;
var registered = await vault.Session
.RegisterDeviceAsync(connection.Account, deviceKeys, name, cancellationToken)
.ConfigureAwait(true);
if (!registered)
{
StatusMessage = "This machine has nowhere to keep a device key.";
return;
}
CanRegisterDevice = false;
StatusMessage = $"'{name}' can now unlock without your passphrase.";
}).ConfigureAwait(true);
}
/// <summary>
/// Takes ownership of a freshly opened session, whichever door opened it.
/// </summary>
/// <remarks>
/// Shared by both unlock paths rather than duplicated, because the ordering in here is load-bearing and
/// a second copy would be a second chance to get it wrong.
/// </remarks>
private async Task AdoptAsync(VaultSession session, CancellationToken cancellationToken)
{
// Before the vault view model, so the first connection after an unlock already knows which host keys
// this user has approved. Reading them is one listing; doing it here rather than lazily is what
// keeps it off the SSH handshake thread.
try
{
await knownHosts.OpenAsync(session, cancellationToken).ConfigureAwait(true);
}
catch
{
// Nothing owns the session yet, so nothing else would ever dispose it — and an undisposed
// session is vault keys left in memory for the life of the process, which is precisely what
// unlocking must be able to undo.
await session.DisposeAsync().ConfigureAwait(true);
throw;
}
Vault = new VaultViewModel(session, workspace, knownHosts, () => connection);
State = ShellState.Unlocked;
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
// has not already registered one. Asked once here rather than recomputed, because the answer
// involves a TPM probe.
CanRegisterDevice = session.Profile.DeviceWrappedPrivateKey is null
&& await deviceKeys.IsAvailableAsync(cancellationToken).ConfigureAwait(true);
await Vault.LoadAsync(cancellationToken).ConfigureAwait(true);
// After the first load, so the list is on screen before anything talks to a server. The loop is
// started from the UI thread deliberately: every pass resumes here, which is what keeps the
// observable collections single-threaded.
Vault.StartAutoSync();
}
/// <summary>
/// Closes the vault and forgets every key it held. Open shells keep running.
/// </summary>
@@ -208,6 +208,16 @@ internal sealed partial class VaultViewModel(
private Task? autoSyncLoop;
private bool disposed;
/// <summary>
/// The open vault this view model is showing.
/// </summary>
/// <remarks>
/// Exposed for the operations only an open session can perform — sealing the bundle to a new device key,
/// chiefly — which the shell drives rather than this view model. Ownership does not move: this type
/// disposes it, and a caller must not.
/// </remarks>
internal VaultSession Session => session;
/// <summary>The hosts to show, unpushed local state included.</summary>
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
+24 -2
View File
@@ -50,6 +50,16 @@
IsVisible="{Binding !IsOnline}" />
<Button Content="Sign in" Command="{Binding SignInCommand}"
IsVisible="{Binding !IsOnline}" />
<!--
Offered only on a machine that can keep a device key and has not already registered one, so it
disappears once used and never appears where it could not work. Its own button rather than a
setting, because it is a one-time decision with a consent dialog attached — and because the
honest place to ask "may this machine unlock itself?" is right after somebody proved they can.
-->
<Button Content="Use Windows Hello here" Command="{Binding RegisterDeviceCommand}"
IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanRegisterDevice}"
ToolTip.Tip="Registers this machine so a later launch can open the vault with a Windows confirmation instead of your passphrase. Your passphrase keeps working." />
<Button Content="Sync" Command="{Binding Vault.SyncCommand}" />
<!--
The tooltip carries the policy to the point of action, because the button's name implies
@@ -265,8 +275,20 @@
-->
<TextBox x:Name="UnlockPassphrase" Text="{Binding Passphrase}"
PlaceholderText="vault passphrase" PasswordChar="•" />
<Button Content="Unlock" Command="{Binding UnlockCommand}"
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Unlock" Command="{Binding UnlockCommand}"
IsEnabled="{Binding !IsBusy}" />
<!--
Shown only when this machine has both a registered wrap and a keystore still willing to
release the key. Absent rather than disabled, because a greyed-out "Use Windows Hello" on a
machine that never had it invites the reading that something is broken — and the passphrase
box beside it is not a fallback, it is the ordinary way in.
-->
<Button Content="Use Windows Hello" Command="{Binding UnlockWithDeviceCommand}"
IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanUnlockWithDevice}"
ToolTip.Tip="Opens the vault with this machine's device key. Windows will ask you to confirm." />
</StackPanel>
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
<TextBlock Classes="hint" FontSize="11"
Text="This works with no network: the salt and the wrapped key are already on this machine." />