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
+11
View File
@@ -29,6 +29,17 @@ public sealed record ClientPaths(string DataDirectory)
/// <summary>The encrypted local cache.</summary>
public string CacheFile => Path.Combine(DataDirectory, "cache.db");
/// <summary>
/// This machine's device key, encrypted to a key it cannot export.
/// </summary>
/// <remarks>
/// Local and non-roaming for a stronger reason than the cache is: the file is decryptable only by a
/// key held in this machine's TPM, so a copy of it on another machine is bytes nothing can open. It
/// following a user to a second computer would be useless rather than dangerous — but a roaming
/// profile that overwrote one machine's blob with another's would break both.
/// </remarks>
public string DeviceKeyFile => Path.Combine(DataDirectory, "device.key");
/// <summary>Creates the profile directory if it is not there yet.</summary>
/// <remarks>
/// Separate from resolving the path, because resolving must never have a side effect: it is read
@@ -0,0 +1,229 @@
using System.Runtime.Versioning;
using System.Security.Cryptography;
namespace DodoSSH.Client.Session;
/// <summary>
/// Picks the device key store this machine can actually offer.
/// </summary>
/// <remarks>
/// One place decides, so nothing above has to carry a platform guard. A machine with no TPM, or one that
/// is not Windows, gets <see cref="UnavailableDeviceKeyStore"/> and therefore keeps asking for the
/// passphrase — which is the honest answer rather than a degraded one.
/// </remarks>
public static class DeviceKeyStores
{
/// <summary>The best store this machine supports.</summary>
public static IDeviceKeyStore ForThisMachine(ClientPaths paths)
{
ArgumentNullException.ThrowIfNull(paths);
return OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported()
? new WindowsDeviceKeyStore(paths)
: new UnavailableDeviceKeyStore();
}
}
/// <summary>
/// Keeps the device key encrypted to a TPM-resident key whose use requires the user's consent.
/// </summary>
/// <remarks>
/// <para>
/// <b>The consent is enforced by CNG, not by this class</b>, and that distinction is the entire security
/// value. A store that read a DPAPI blob after showing its own prompt would be trivially bypassed:
/// malware running as the user would skip the prompt, read the file and call
/// <c>CryptUnprotectData</c> itself. Here the unwrapping key lives in the TPM under
/// <see cref="CngUIProtectionLevels.ProtectKey"/>, so the Windows consent dialog is a condition of
/// <em>using</em> the key. Malware can ask; it cannot answer, and the attempt is visible.
/// </para>
/// <para>
/// This is a refinement of what ADR 0007 describes, and stronger than it: the ADR reasoned about a
/// gesture gating a protected blob and did not notice that a gate inside the process is not a gate. The
/// mechanism recorded there has been corrected to match this.
/// </para>
/// <para>
/// The TPM key is RSA rather than the ECDH one might expect, because it is used to encrypt 32 bytes and
/// nothing else. OAEP over a 2048-bit key carries 190, so there is no need for an agreement step, and no
/// need for the device key itself to be an algorithm the TPM understands — which is what keeps the DSH1
/// device wrap unchanged at X25519. See ADR 0007 for why changing that is a separate decision.
/// </para>
/// <para>
/// <b>Both ends prompt, and that was measured rather than assumed.</b> Encrypting uses only the public
/// half, so it was reasonable to expect sealing to be silent — it is not. <c>CngKey.Create</c> with this
/// UI policy blocks on a dialog at <em>creation</em>, because <c>ProtectKey</c> means "protect this key
/// with a PIN", and Windows asks the user to set that up there and then. So registering a device shows one
/// setup dialog and every unlock shows a consent dialog.
/// </para>
/// <para>
/// That is the right shape for an opt-in feature, but it has two consequences worth knowing before
/// touching this file. Every method except <see cref="IsSupported"/> and the empty case of
/// <see cref="TryLoadAsync"/> needs an interactive desktop, so <b>none of them can be exercised by an
/// automated test</b> — see <c>WindowsDeviceKeyStoreTests</c> for where that line falls. And a caller must
/// treat <see cref="SaveAsync"/> as user-facing: it belongs on a UI thread, behind a button somebody
/// pressed, never on a background pass.
/// </para>
/// </remarks>
[SupportedOSPlatform("windows")]
public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
{
/// <remarks>
/// Versioned, so a future change of algorithm or padding can create a new key beside the old one
/// rather than failing to open blobs written by a previous build. A device that cannot be opened
/// falls back to the passphrase, which is survivable — but silently, and a user would only notice
/// their gesture had stopped working.
/// </remarks>
private const string KeyName = "DodoSSH.DeviceKey.v1";
private const string PlatformProvider = "Microsoft Platform Crypto Provider";
/// <remarks>Shown in the Windows consent dialog, so it has to read as a sentence to a person.</remarks>
private const string ConsentPrompt = "Unlock your DodoSSH vault";
private readonly ClientPaths paths;
/// <summary>Creates the store.</summary>
public WindowsDeviceKeyStore(ClientPaths paths)
{
ArgumentNullException.ThrowIfNull(paths);
this.paths = paths;
}
/// <summary>
/// Whether this machine has a TPM the platform provider will hold a key in.
/// </summary>
/// <remarks>
/// Probed by creating a throwaway key and deleting it, rather than by asking the provider whether it
/// exists. The provider is registered on machines with no usable TPM as well, and reports itself
/// present right up to the point where creating a key fails — so the only honest test is the one that
/// does the thing. No UI policy on the probe, so nothing prompts.
/// </remarks>
internal static bool IsSupported()
{
var probe = $"DodoSSH.Probe.{Guid.CreateVersion7():N}";
try
{
using var key = CngKey.Create(
CngAlgorithm.Rsa,
probe,
new CngKeyCreationParameters { Provider = new CngProvider(PlatformProvider) });
key.Delete();
return true;
}
catch (CryptographicException)
{
return false;
}
catch (PlatformNotSupportedException)
{
return false;
}
}
/// <inheritdoc />
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
ValueTask.FromResult(IsSupported());
/// <inheritdoc />
public async ValueTask SaveAsync(
ReadOnlyMemory<byte> devicePrivateKey,
CancellationToken cancellationToken)
{
using var key = OpenOrCreate();
using var rsa = new RSACng(key);
var sealedKey = rsa.Encrypt(devicePrivateKey.Span, RSAEncryptionPadding.OaepSHA256);
paths.EnsureCreated();
await File.WriteAllBytesAsync(paths.DeviceKeyFile, sealedKey, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken)
{
if (!File.Exists(paths.DeviceKeyFile))
{
return null;
}
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
.ConfigureAwait(false);
return Unseal(sealedKey);
}
/// <inheritdoc />
public ValueTask ForgetAsync(CancellationToken cancellationToken)
{
if (File.Exists(paths.DeviceKeyFile))
{
File.Delete(paths.DeviceKeyFile);
}
if (CngKey.Exists(KeyName, new CngProvider(PlatformProvider)))
{
using var key = CngKey.Open(KeyName, new CngProvider(PlatformProvider));
key.Delete();
}
return ValueTask.CompletedTask;
}
/// <remarks>
/// This is the call that prompts. Every failure becomes null, and the set is wider than it looks: the
/// key may be gone, the user may have cancelled, the TPM may be locked out after too many wrong PINs,
/// or the blob may predate a key that has since been replaced. None of them are distinguishable to a
/// user and all have the same remedy, so none of them are worth telling apart here — see
/// <c>UnlockStatus.DeviceKeyUnavailable</c>.
/// </remarks>
private static byte[]? Unseal(byte[] sealedKey)
{
try
{
if (!CngKey.Exists(KeyName, new CngProvider(PlatformProvider)))
{
return null;
}
using var key = CngKey.Open(KeyName, new CngProvider(PlatformProvider));
using var rsa = new RSACng(key);
return rsa.Decrypt(sealedKey, RSAEncryptionPadding.OaepSHA256);
}
catch (CryptographicException)
{
return null;
}
}
/// <remarks>
/// The UI policy is set at creation and cannot be added afterwards, which is why this opens an existing
/// key rather than ever reconfiguring one: a key created without the policy would decrypt silently, and
/// silently is the one behaviour this whole file exists to prevent.
/// </remarks>
private static CngKey OpenOrCreate()
{
var provider = new CngProvider(PlatformProvider);
if (CngKey.Exists(KeyName, provider))
{
return CngKey.Open(KeyName, provider);
}
return CngKey.Create(
CngAlgorithm.Rsa,
KeyName,
new CngKeyCreationParameters
{
Provider = provider,
UIPolicy = new CngUIPolicy(CngUIProtectionLevels.ProtectKey, ConsentPrompt),
// Machine-wide would put one key behind every account on the computer. This key stands for
// "this user, on this machine", which is what a device wrap means.
KeyCreationOptions = CngKeyCreationOptions.None,
});
}
}