diff --git a/docs/adr/0007-device-key-protection.md b/docs/adr/0007-device-key-protection.md
index 1a89f6e..b8f38f5 100644
--- a/docs/adr/0007-device-key-protection.md
+++ b/docs/adr/0007-device-key-protection.md
@@ -33,11 +33,47 @@ leaves hardware" would be false under all of them.
## Decision
-**A Windows Hello gesture gating a protected blob, with the passphrase kept as a permanent fallback.**
+**A TPM-resident key whose use requires the user's consent, with the passphrase kept as a permanent
+fallback.**
-The gesture is what carries the security value: it requires **user presence** per unlock. Hello cannot
-decrypt, so it is used to gate release of the wrapping key, and the passphrase path remains available
-unconditionally.
+User presence per unlock is what carries the security value. What changed between this decision and its
+implementation is *who enforces the presence*, and the change was a correction rather than a refinement.
+
+> **Amended 2026-07-30.** This section originally read "a Windows Hello gesture gating a protected blob".
+> That design does not deliver what the rest of this document claims for it, and the flaw is worth keeping
+> on the record: **a gate inside the process is not a gate.** A store that showed a Hello prompt and then
+> read a DPAPI blob would be bypassed by malware that skipped the prompt, read the file and called
+> `CryptUnprotectData` itself. 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, and the attempt is visible. `System.Security.Cryptography.CngKey` is in-box, so this needs no WinRT
+projection and **no Windows target framework** — a plain platform guard is enough.
+
+RSA rather than an agreement algorithm because the payload is 32 bytes and OAEP over 2048 bits carries 190.
+That also keeps the DSH1 device wrap unchanged at X25519: the TPM key protects the device key, it does not
+replace it.
+
+Availability is probed by creating a throwaway key and deleting it, not by asking whether the provider is
+registered — it is registered on machines with no usable TPM too, and reports itself present right up to
+the point where creating a key fails.
+
+### What was measured, and what it cost
+
+Two things were verified on real hardware rather than assumed, and one of them changed the design's shape:
+
+- **The platform provider works** and holds an RSA key: confirmed by creating and deleting one.
+- **`ProtectKey` prompts at key *creation*, not only at use.** `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.
+
+The second has consequences. Registering a device shows a setup dialog and every unlock shows a consent
+dialog, which is the right shape for an opt-in feature — but it means **`SaveAsync` is user-facing code**
+that belongs on a UI thread behind a button somebody pressed, and it means almost nothing in the store can
+be covered by an automated test. That was found by writing those tests and watching a suite hang for ten
+minutes waiting for a PIN. Two tests remain: availability, and the empty case that provably reaches no
+dialog.
### Why not DPAPI alone
@@ -55,9 +91,14 @@ Neither defends the *local malware* case. The gesture does.
### Why not extend the spec (yet)
-The only option that delivers what the TPM is usually credited with is to add a `SealTo` algorithm over
-a curve the TPM can do — `alg_id = 4` over P-256 — so the device private key never exists in process
-memory at all. That is **the recorded target**, not this decision.
+The device *wrapping* key now genuinely never leaves the TPM, which is most of what option D promised. What
+remains is that the X25519 device key itself is reassembled in process memory to open the wrap, because DSH1
+fixes that wrap at a curve the TPM cannot do.
+
+Closing that last gap means adding a `SealTo` algorithm over a curve the TPM can do — `alg_id = 4` over
+P-256 — so the device key never exists outside hardware at all. That is **the recorded target**, not this
+decision, and it is now a smaller step than it was: the keystore plumbing, the endpoint and the unlock path
+would all be unchanged.
It is cheaper than "change a frozen spec" sounds, because a device wrap row is read only by the device
that created it: not by another client, and not by the server. The envelope already carries `alg_id`
@@ -97,11 +138,12 @@ would have become false under DPAPI alone. A gesture is still something the atta
### Operational
-- **Hello is not always available.** No biometric hardware falls back to a Hello PIN, which is
- TPM-bound and rate-limited and still satisfies the presence requirement. Some machines have no Hello
- at all. The passphrase path is therefore required, not a nicety.
-- **Hello keys are invalidated when the PIN is reset**, so the blob must be treated as losable at any
- time; losing it degrades to a passphrase prompt and never to a locked-out vault.
+- **A TPM is not always there.** A machine without one gets a store that reports itself unavailable, so
+ unlock keeps asking for the passphrase and neither affordance appears in the interface. The passphrase path
+ is therefore required, not a nicety.
+- **The stored key must be treated as losable at any time** — a reset PIN, a cleared TPM, a replaced key.
+ Every loss degrades to a passphrase prompt and never to a locked-out vault, which is why every failure in
+ the store returns null rather than throwing and why the three unlock statuses all end in the same advice.
- **Registering a device is a separate act from enrolling one.** `EnrollmentService.AddDevice` runs only
during enrollment, so every already-enrolled account — which is all of them — needs an endpoint to add
a device wrap while unlocked. Producing the wrap requires the bundle, so the client proves possession
diff --git a/src/DodoSSH.Client.App/App.axaml.cs b/src/DodoSSH.Client.App/App.axaml.cs
index 425a2ac..1b8496a 100644
--- a/src/DodoSSH.Client.App/App.axaml.cs
+++ b/src/DodoSSH.Client.App/App.axaml.cs
@@ -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);
+ }
+
+ ///
+ /// 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.
+ ///
+ 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)
diff --git a/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs
index ae549c8..a448c77 100644
--- a/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs
+++ b/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs
@@ -68,6 +68,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
///
private readonly VaultKnownHostStore knownHosts;
+ ///
+ /// 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.
+ ///
+ 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;
+ /// Whether the unlock screen should offer a gesture instead of the passphrase.
+ [ObservableProperty]
+ private bool canUnlockWithDevice;
+
+ /// Whether an unlocked vault should offer to register this machine.
+ [ObservableProperty]
+ private bool canRegisterDevice;
+
///
/// The address dotnet run --project src/DodoSSH.Api actually serves, so the first launch after
/// a clone works without the user having to know a port. This was https://localhost:7217, 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);
}
+ /// Opens the vault with this machine's device key instead of the passphrase.
+ ///
+ /// No Task.Run, 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.
+ ///
+ [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);
+ }
+
+ ///
+ /// Registers this machine so a later launch can unlock with a gesture.
+ ///
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+
+ ///
+ /// Takes ownership of a freshly opened session, whichever door opened it.
+ ///
+ ///
+ /// 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.
+ ///
+ 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();
+ }
+
///
/// Closes the vault and forgets every key it held. Open shells keep running.
///
diff --git a/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs
index 2c96901..4e24d03 100644
--- a/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs
+++ b/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs
@@ -208,6 +208,16 @@ internal sealed partial class VaultViewModel(
private Task? autoSyncLoop;
private bool disposed;
+ ///
+ /// The open vault this view model is showing.
+ ///
+ ///
+ /// 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.
+ ///
+ internal VaultSession Session => session;
+
/// The hosts to show, unpushed local state included.
internal ObservableCollection Hosts { get; } = [];
diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml b/src/DodoSSH.Client.App/Views/MainWindow.axaml
index 40a4941..b6dd128 100644
--- a/src/DodoSSH.Client.App/Views/MainWindow.axaml
+++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml
@@ -50,6 +50,16 @@
IsVisible="{Binding !IsOnline}" />
+
+
-
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Session/ClientPaths.cs b/src/DodoSSH.Client.Session/ClientPaths.cs
index 874f820..cae4723 100644
--- a/src/DodoSSH.Client.Session/ClientPaths.cs
+++ b/src/DodoSSH.Client.Session/ClientPaths.cs
@@ -29,6 +29,17 @@ public sealed record ClientPaths(string DataDirectory)
/// The encrypted local cache.
public string CacheFile => Path.Combine(DataDirectory, "cache.db");
+ ///
+ /// This machine's device key, encrypted to a key it cannot export.
+ ///
+ ///
+ /// 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.
+ ///
+ public string DeviceKeyFile => Path.Combine(DataDirectory, "device.key");
+
/// Creates the profile directory if it is not there yet.
///
/// Separate from resolving the path, because resolving must never have a side effect: it is read
diff --git a/src/DodoSSH.Client.Session/WindowsDeviceKeyStore.cs b/src/DodoSSH.Client.Session/WindowsDeviceKeyStore.cs
new file mode 100644
index 0000000..a396fcd
--- /dev/null
+++ b/src/DodoSSH.Client.Session/WindowsDeviceKeyStore.cs
@@ -0,0 +1,229 @@
+using System.Runtime.Versioning;
+using System.Security.Cryptography;
+
+namespace DodoSSH.Client.Session;
+
+///
+/// Picks the device key store this machine can actually offer.
+///
+///
+/// One place decides, so nothing above has to carry a platform guard. A machine with no TPM, or one that
+/// is not Windows, gets and therefore keeps asking for the
+/// passphrase — which is the honest answer rather than a degraded one.
+///
+public static class DeviceKeyStores
+{
+ /// The best store this machine supports.
+ public static IDeviceKeyStore ForThisMachine(ClientPaths paths)
+ {
+ ArgumentNullException.ThrowIfNull(paths);
+
+ return OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported()
+ ? new WindowsDeviceKeyStore(paths)
+ : new UnavailableDeviceKeyStore();
+ }
+}
+
+///
+/// Keeps the device key encrypted to a TPM-resident key whose use requires the user's consent.
+///
+///
+///
+/// The consent is enforced by CNG, not by this class, 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
+/// CryptUnprotectData itself. Here the unwrapping key lives in the TPM under
+/// , so the Windows consent dialog is a condition of
+/// using the key. Malware can ask; it cannot answer, and the attempt is visible.
+///
+///
+/// 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.
+///
+///
+/// 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.
+///
+///
+/// Both ends prompt, and that was measured rather than assumed. Encrypting uses only the public
+/// half, so it was reasonable to expect sealing to be silent — it is not. CngKey.Create with this
+/// UI policy blocks on a dialog at creation, because ProtectKey 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.
+///
+///
+/// That is the right shape for an opt-in feature, but it has two consequences worth knowing before
+/// touching this file. Every method except and the empty case of
+/// needs an interactive desktop, so none of them can be exercised by an
+/// automated test — see WindowsDeviceKeyStoreTests for where that line falls. And a caller must
+/// treat as user-facing: it belongs on a UI thread, behind a button somebody
+/// pressed, never on a background pass.
+///
+///
+[SupportedOSPlatform("windows")]
+public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
+{
+ ///
+ /// 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.
+ ///
+ private const string KeyName = "DodoSSH.DeviceKey.v1";
+
+ private const string PlatformProvider = "Microsoft Platform Crypto Provider";
+
+ /// Shown in the Windows consent dialog, so it has to read as a sentence to a person.
+ private const string ConsentPrompt = "Unlock your DodoSSH vault";
+
+ private readonly ClientPaths paths;
+
+ /// Creates the store.
+ public WindowsDeviceKeyStore(ClientPaths paths)
+ {
+ ArgumentNullException.ThrowIfNull(paths);
+ this.paths = paths;
+ }
+
+ ///
+ /// Whether this machine has a TPM the platform provider will hold a key in.
+ ///
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+ }
+
+ ///
+ public ValueTask IsAvailableAsync(CancellationToken cancellationToken) =>
+ ValueTask.FromResult(IsSupported());
+
+ ///
+ public async ValueTask SaveAsync(
+ ReadOnlyMemory 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);
+ }
+
+ ///
+ public async ValueTask TryLoadAsync(CancellationToken cancellationToken)
+ {
+ if (!File.Exists(paths.DeviceKeyFile))
+ {
+ return null;
+ }
+
+ var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
+ .ConfigureAwait(false);
+
+ return Unseal(sealedKey);
+ }
+
+ ///
+ 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;
+ }
+
+ ///
+ /// 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
+ /// UnlockStatus.DeviceKeyUnavailable.
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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,
+ });
+ }
+}
diff --git a/tests/DodoSSH.Client.App.Tests/DodoSSH.Client.App.Tests.csproj b/tests/DodoSSH.Client.App.Tests/DodoSSH.Client.App.Tests.csproj
index a4f7165..b8c9088 100644
--- a/tests/DodoSSH.Client.App.Tests/DodoSSH.Client.App.Tests.csproj
+++ b/tests/DodoSSH.Client.App.Tests/DodoSSH.Client.App.Tests.csproj
@@ -15,4 +15,15 @@
+
+
+
+
+
diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
index d785320..a2edebf 100644
--- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
@@ -1,5 +1,9 @@
using DodoSSH.Client.App.ViewModels;
using DodoSSH.Client.Session;
+
+// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
+// see the csproj for why it is shared rather than reimplemented.
+using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
@@ -43,6 +47,14 @@ public sealed class ShellFlowTests : IAsyncLifetime
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private VaultKnownHostStore knownHosts = null!;
+
+ ///
+ /// A fake rather than the real TPM-backed store, and not for speed: the real one prompts for a Windows
+ /// consent dialog on every save and every load, so a suite using it would block forever waiting for
+ /// somebody to enter a PIN. What the shell has to get right is which buttons appear and what happens when
+ /// one is pressed, and that is exactly what a fake keystore can answer.
+ ///
+ private FakeDeviceKeyStore deviceKeys = null!;
private MainWindowViewModel shell = null!;
///
@@ -58,6 +70,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
// shell's business — opened on unlock, closed on lock — and the trust it records goes into the vault
// this suite already has, so substituting one would only stop the wiring being tested.
knownHosts = new VaultKnownHostStore();
+ deviceKeys = new FakeDeviceKeyStore();
// In-memory assets rather than the application's Avalonia-resource provider, which reads the
// resource system at construction and needs an initialised toolkit. This is what
@@ -94,6 +107,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
caches,
workspace,
knownHosts,
+ deviceKeys,
SignInAsync,
TimeProvider.System,
CheapProfile);
@@ -276,6 +290,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
caches,
workspace,
new VaultKnownHostStore(),
+ new UnavailableDeviceKeyStore(),
(_, _) => throw new InvalidOperationException("The shell went to the network to unlock."),
TimeProvider.System,
CheapProfile);
@@ -720,6 +735,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
caches,
workspace,
new VaultKnownHostStore(),
+ new UnavailableDeviceKeyStore(),
(_, _) => throw new InvalidOperationException("unreachable"),
TimeProvider.System,
CheapProfile);
@@ -1251,6 +1267,73 @@ public sealed class ShellFlowTests : IAsyncLifetime
private Task ReadyToUnlockAsync() => EnrolledAndConfirmedAsync();
+ // ---- Unlocking with this machine's device key ----
+
+ [Fact]
+ public async Task AnUnlockedVaultOffersToRegisterThisMachine()
+ {
+ await UnlockedAsync();
+
+ shell.CanRegisterDevice.ShouldBeTrue();
+
+ // Not before: a locked machine with nothing registered has nothing to offer, and the unlock screen
+ // must not show a gesture button for a key it does not have.
+ shell.CanUnlockWithDevice.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task RegisteringThenRelaunching_UnlocksWithTheGestureAndNoPassphrase()
+ {
+ // The shell's half of the feature, end to end through the commands a user actually presses.
+ await UnlockedAsync();
+ await shell.RegisterDeviceCommand.ExecuteAsync(null);
+
+ shell.CanRegisterDevice.ShouldBeFalse("it is registered now, so the offer is spent");
+ server.RegisteredDevices.Count.ShouldBe(1);
+
+ await shell.LockCommand.ExecuteAsync(null);
+ await shell.StartAsync(Token);
+
+ shell.CanUnlockWithDevice.ShouldBeTrue("the wrap is cached and the keystore still has the key");
+
+ await shell.UnlockWithDeviceCommand.ExecuteAsync(null);
+
+ shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
+ shell.Passphrase.ShouldBeEmpty("nothing was typed");
+ }
+
+ [Fact]
+ public async Task OnAMachineWithNoKeystore_NeitherAffordanceAppears()
+ {
+ deviceKeys.IsAvailable = false;
+
+ await UnlockedAsync();
+
+ shell.CanRegisterDevice.ShouldBeFalse();
+ shell.CanUnlockWithDevice.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task ADeclinedGesture_LeavesTheUnlockScreenUsable()
+ {
+ // The fallback that makes the whole thing safe to offer: a cancelled prompt changes the message and
+ // nothing else, and the passphrase still opens the vault.
+ await UnlockedAsync();
+ await shell.RegisterDeviceCommand.ExecuteAsync(null);
+ await shell.LockCommand.ExecuteAsync(null);
+ await shell.StartAsync(Token);
+
+ deviceKeys.Decline = true;
+ await shell.UnlockWithDeviceCommand.ExecuteAsync(null);
+
+ shell.State.ShouldBe(ShellState.Locked);
+
+ shell.Passphrase = Passphrase;
+ await shell.UnlockCommand.ExecuteAsync(null);
+
+ shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
+ }
+
private async Task UnlockedAsync()
{
await EnrolledAndConfirmedAsync();
diff --git a/tests/DodoSSH.Client.Session.Tests/DeviceUnlockTests.cs b/tests/DodoSSH.Client.Session.Tests/DeviceUnlockTests.cs
index 8b41ba1..0fc8e24 100644
--- a/tests/DodoSSH.Client.Session.Tests/DeviceUnlockTests.cs
+++ b/tests/DodoSSH.Client.Session.Tests/DeviceUnlockTests.cs
@@ -254,49 +254,3 @@ public sealed class DeviceUnlockTests : IAsyncLifetime
}
///
-/// A device key store that keeps its key in a field.
-///
-///
-/// 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. is a cancelled prompt and an invalidated key at once, which is exactly how much
-/// the caller is allowed to know.
-///
-internal sealed class FakeDeviceKeyStore : IDeviceKeyStore
-{
- private byte[]? key;
-
- /// When set, the next load refuses, as a cancelled gesture does.
- internal bool Decline { get; set; }
-
- /// Whether this machine can keep a key at all.
- internal bool IsAvailable { get; set; } = true;
-
- /// Reads the stored key without a gesture, for assertions only.
- internal byte[]? Peek() => key;
-
- /// Replaces the stored key, standing in for a rotated or corrupted keystore entry.
- internal void Overwrite(byte[] replacement) => key = replacement;
-
- ///
- public ValueTask IsAvailableAsync(CancellationToken cancellationToken) =>
- ValueTask.FromResult(IsAvailable);
-
- ///
- public ValueTask SaveAsync(ReadOnlyMemory devicePrivateKey, CancellationToken cancellationToken)
- {
- key = devicePrivateKey.ToArray();
- return ValueTask.CompletedTask;
- }
-
- ///
- public ValueTask TryLoadAsync(CancellationToken cancellationToken) =>
- ValueTask.FromResult(Decline ? null : key?.ToArray());
-
- ///
- public ValueTask ForgetAsync(CancellationToken cancellationToken)
- {
- key = null;
- return ValueTask.CompletedTask;
- }
-}
diff --git a/tests/DodoSSH.Client.Session.Tests/FakeDeviceKeyStore.cs b/tests/DodoSSH.Client.Session.Tests/FakeDeviceKeyStore.cs
new file mode 100644
index 0000000..8f9d808
--- /dev/null
+++ b/tests/DodoSSH.Client.Session.Tests/FakeDeviceKeyStore.cs
@@ -0,0 +1,48 @@
+namespace DodoSSH.Client.Session.Tests;
+
+/// A device key store that keeps its key in a field.
+///
+///
+/// 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. is a cancelled prompt and an invalidated key at once, which is exactly how much
+/// the caller is allowed to know.
+///
+internal sealed class FakeDeviceKeyStore : IDeviceKeyStore
+{
+ private byte[]? key;
+
+ /// When set, the next load refuses, as a cancelled gesture does.
+ internal bool Decline { get; set; }
+
+ /// Whether this machine can keep a key at all.
+ internal bool IsAvailable { get; set; } = true;
+
+ /// Reads the stored key without a gesture, for assertions only.
+ internal byte[]? Peek() => key;
+
+ /// Replaces the stored key, standing in for a rotated or corrupted keystore entry.
+ internal void Overwrite(byte[] replacement) => key = replacement;
+
+ ///
+ public ValueTask IsAvailableAsync(CancellationToken cancellationToken) =>
+ ValueTask.FromResult(IsAvailable);
+
+ ///
+ public ValueTask SaveAsync(ReadOnlyMemory devicePrivateKey, CancellationToken cancellationToken)
+ {
+ key = devicePrivateKey.ToArray();
+ return ValueTask.CompletedTask;
+ }
+
+ ///
+ public ValueTask TryLoadAsync(CancellationToken cancellationToken) =>
+ ValueTask.FromResult(Decline ? null : key?.ToArray());
+
+ ///
+ public ValueTask ForgetAsync(CancellationToken cancellationToken)
+ {
+ key = null;
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/tests/DodoSSH.Client.Session.Tests/WindowsDeviceKeyStoreTests.cs b/tests/DodoSSH.Client.Session.Tests/WindowsDeviceKeyStoreTests.cs
new file mode 100644
index 0000000..94b39c9
--- /dev/null
+++ b/tests/DodoSSH.Client.Session.Tests/WindowsDeviceKeyStoreTests.cs
@@ -0,0 +1,75 @@
+using System.Runtime.Versioning;
+
+namespace DodoSSH.Client.Session.Tests;
+
+///
+/// The real TPM-backed store, as far as it can be exercised without a person. Which is not far.
+///
+///
+///
+/// Two tests, and the reason there are only two is a measured finding. A key created under
+/// CngUIProtectionLevels.ProtectKey prompts at creation, 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.
+///
+///
+/// So anything that calls SaveAsync, TryLoadAsync with a blob present, or ForgetAsync
+/// 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.
+///
+///
+/// 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.
+///
+///
+[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();
+ (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.");
+ }
+ }
+}