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
@@ -254,49 +254,3 @@ public sealed class DeviceUnlockTests : IAsyncLifetime
}
/// <summary>
/// A device key store that keeps its key in a field.
/// </summary>
/// <remarks>
/// Stands in for whatever guards the key on a real machine. The gesture is the entire security value of the
/// real thing, so what this fake models is the two ways the gesture ends: it hands the key over, or it does
/// not. <see cref="Decline"/> is a cancelled prompt and an invalidated key at once, which is exactly how much
/// the caller is allowed to know.
/// </remarks>
internal sealed class FakeDeviceKeyStore : IDeviceKeyStore
{
private byte[]? key;
/// <summary>When set, the next load refuses, as a cancelled gesture does.</summary>
internal bool Decline { get; set; }
/// <summary>Whether this machine can keep a key at all.</summary>
internal bool IsAvailable { get; set; } = true;
/// <summary>Reads the stored key without a gesture, for assertions only.</summary>
internal byte[]? Peek() => key;
/// <summary>Replaces the stored key, standing in for a rotated or corrupted keystore entry.</summary>
internal void Overwrite(byte[] replacement) => key = replacement;
/// <inheritdoc />
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
ValueTask.FromResult(IsAvailable);
/// <inheritdoc />
public ValueTask SaveAsync(ReadOnlyMemory<byte> devicePrivateKey, CancellationToken cancellationToken)
{
key = devicePrivateKey.ToArray();
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken) =>
ValueTask.FromResult(Decline ? null : key?.ToArray());
/// <inheritdoc />
public ValueTask ForgetAsync(CancellationToken cancellationToken)
{
key = null;
return ValueTask.CompletedTask;
}
}
@@ -0,0 +1,48 @@
namespace DodoSSH.Client.Session.Tests;
/// A device key store that keeps its key in a field.
/// </summary>
/// <remarks>
/// Stands in for whatever guards the key on a real machine. The gesture is the entire security value of the
/// real thing, so what this fake models is the two ways the gesture ends: it hands the key over, or it does
/// not. <see cref="Decline"/> is a cancelled prompt and an invalidated key at once, which is exactly how much
/// the caller is allowed to know.
/// </remarks>
internal sealed class FakeDeviceKeyStore : IDeviceKeyStore
{
private byte[]? key;
/// <summary>When set, the next load refuses, as a cancelled gesture does.</summary>
internal bool Decline { get; set; }
/// <summary>Whether this machine can keep a key at all.</summary>
internal bool IsAvailable { get; set; } = true;
/// <summary>Reads the stored key without a gesture, for assertions only.</summary>
internal byte[]? Peek() => key;
/// <summary>Replaces the stored key, standing in for a rotated or corrupted keystore entry.</summary>
internal void Overwrite(byte[] replacement) => key = replacement;
/// <inheritdoc />
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
ValueTask.FromResult(IsAvailable);
/// <inheritdoc />
public ValueTask SaveAsync(ReadOnlyMemory<byte> devicePrivateKey, CancellationToken cancellationToken)
{
key = devicePrivateKey.ToArray();
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken) =>
ValueTask.FromResult(Decline ? null : key?.ToArray());
/// <inheritdoc />
public ValueTask ForgetAsync(CancellationToken cancellationToken)
{
key = null;
return ValueTask.CompletedTask;
}
}
@@ -0,0 +1,75 @@
using System.Runtime.Versioning;
namespace DodoSSH.Client.Session.Tests;
/// <summary>
/// The real TPM-backed store, as far as it can be exercised without a person. Which is not far.
/// </summary>
/// <remarks>
/// <para>
/// <b>Two tests, and the reason there are only two is a measured finding.</b> A key created under
/// <c>CngUIProtectionLevels.ProtectKey</c> prompts at <em>creation</em>, not only at use: the policy means
/// "protect this key with a PIN", so Windows asks the user to set that up when the key is made. Sealing
/// therefore prompts as well as opening, even though sealing needs only the public half.
/// </para>
/// <para>
/// So anything that calls <c>SaveAsync</c>, <c>TryLoadAsync</c> with a blob present, or <c>ForgetAsync</c>
/// after a save will block a suite forever waiting for somebody to enter a PIN. That was found by writing
/// those tests and watching the run hang for ten minutes. They are gone; what is left is the two paths that
/// provably reach no dialog.
/// </para>
/// <para>
/// The rest of this store is verified by using the application, and that is not a gap this file can close —
/// a consent dialog needs hardware and a person by design. Disabling the UI policy to make it testable
/// would be testing a different class, and the one property worth having would be the property removed.
/// </para>
/// </remarks>
[SupportedOSPlatform("windows")]
public sealed class WindowsDeviceKeyStoreTests
{
private static CancellationToken Token => TestContext.Current.CancellationToken;
[Fact]
public async Task OnAMachineWithATpm_TheStoreOffersItself()
{
// IsSupported probes with a throwaway key carrying no UI policy, which is why this one is safe to
// run: no policy, no dialog. It is also the only honest availability test, because the platform
// provider reports itself present on machines where creating a key then fails.
SkipUnlessSupported();
var store = DeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()));
store.ShouldBeOfType<WindowsDeviceKeyStore>();
(await store.IsAvailableAsync(Token)).ShouldBeTrue();
}
[Fact]
public async Task WithNothingSaved_LoadingReturnsNullWithoutPrompting()
{
// Reaches no dialog because it returns on the missing file, before touching the TPM at all. That is
// also what keeps a fresh machine's unlock screen quiet: it must not prompt for a key it has never
// been given. If this test ever hangs, that ordering has been lost.
SkipUnlessSupported();
var directory = Path.Combine(Path.GetTempPath(), $"dodossh-devicekey-{Guid.CreateVersion7():N}");
var store = new WindowsDeviceKeyStore(new ClientPaths(directory));
(await store.TryLoadAsync(Token)).ShouldBeNull();
// Nothing was created, so there is nothing to clean up — asserted, because a store that wrote a
// directory just to answer "no" would be leaving litter on every launch of an unregistered machine.
Directory.Exists(directory).ShouldBeFalse();
}
private static void SkipUnlessSupported()
{
var supported = OperatingSystem.IsWindows()
&& DeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()))
is WindowsDeviceKeyStore;
if (!supported)
{
Assert.Skip("This machine has no TPM the platform crypto provider will hold a key in.");
}
}
}