Public Access
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:
@@ -33,11 +33,47 @@ leaves hardware" would be false under all of them.
|
|||||||
|
|
||||||
## Decision
|
## 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
|
User presence per unlock is what carries the security value. What changed between this decision and its
|
||||||
decrypt, so it is used to gate release of the wrapping key, and the passphrase path remains available
|
implementation is *who enforces the presence*, and the change was a correction rather than a refinement.
|
||||||
unconditionally.
|
|
||||||
|
> **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
|
### Why not DPAPI alone
|
||||||
|
|
||||||
@@ -55,9 +91,14 @@ Neither defends the *local malware* case. The gesture does.
|
|||||||
|
|
||||||
### Why not extend the spec (yet)
|
### 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
|
The device *wrapping* key now genuinely never leaves the TPM, which is most of what option D promised. What
|
||||||
a curve the TPM can do — `alg_id = 4` over P-256 — so the device private key never exists in process
|
remains is that the X25519 device key itself is reassembled in process memory to open the wrap, because DSH1
|
||||||
memory at all. That is **the recorded target**, not this decision.
|
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
|
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`
|
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
|
### Operational
|
||||||
|
|
||||||
- **Hello is not always available.** No biometric hardware falls back to a Hello PIN, which is
|
- **A TPM is not always there.** A machine without one gets a store that reports itself unavailable, so
|
||||||
TPM-bound and rate-limited and still satisfies the presence requirement. Some machines have no Hello
|
unlock keeps asking for the passphrase and neither affordance appears in the interface. The passphrase path
|
||||||
at all. The passphrase path is therefore required, not a nicety.
|
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
|
- **The stored key must be treated as losable at any time** — a reset PIN, a cleared TPM, a replaced key.
|
||||||
time; losing it degrades to a passphrase prompt and never to a locked-out vault.
|
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
|
- **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
|
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
|
a device wrap while unlocked. Producing the wrap requires the bundle, so the client proves possession
|
||||||
|
|||||||
@@ -67,11 +67,17 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
|
|
||||||
var browser = new SystemBrowserLauncher();
|
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(
|
var viewModel = new MainWindowViewModel(
|
||||||
paths,
|
paths,
|
||||||
caches,
|
caches,
|
||||||
workspace,
|
workspace,
|
||||||
knownHosts,
|
knownHosts,
|
||||||
|
deviceKeys,
|
||||||
async (url, cancellationToken) => await ServerConnection
|
async (url, cancellationToken) => await ServerConnection
|
||||||
.SignInAsync(url, browser, TimeProvider.System, cancellationToken)
|
.SignInAsync(url, browser, TimeProvider.System, cancellationToken)
|
||||||
.ConfigureAwait(false),
|
.ConfigureAwait(false),
|
||||||
@@ -84,11 +90,22 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
// discarding the task here is safe rather than merely convenient.
|
// discarding the task here is safe rather than merely convenient.
|
||||||
_ = viewModel.StartAsync(CancellationToken.None);
|
_ = 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;
|
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) =>
|
desktop.ShutdownRequested += async (_, e) =>
|
||||||
{
|
{
|
||||||
if (shuttingDown)
|
if (shuttingDown)
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private readonly VaultKnownHostStore knownHosts;
|
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 SignInHandler signIn;
|
||||||
private readonly TimeProvider clock;
|
private readonly TimeProvider clock;
|
||||||
private readonly Argon2Profile? passphraseProfile;
|
private readonly Argon2Profile? passphraseProfile;
|
||||||
@@ -91,6 +98,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
ClientCacheFactory caches,
|
ClientCacheFactory caches,
|
||||||
TerminalWorkspace workspace,
|
TerminalWorkspace workspace,
|
||||||
VaultKnownHostStore knownHosts,
|
VaultKnownHostStore knownHosts,
|
||||||
|
IDeviceKeyStore deviceKeys,
|
||||||
SignInHandler signIn,
|
SignInHandler signIn,
|
||||||
TimeProvider clock,
|
TimeProvider clock,
|
||||||
Argon2Profile? passphraseProfile = null)
|
Argon2Profile? passphraseProfile = null)
|
||||||
@@ -99,6 +107,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
this.caches = caches;
|
this.caches = caches;
|
||||||
this.workspace = workspace;
|
this.workspace = workspace;
|
||||||
this.knownHosts = knownHosts;
|
this.knownHosts = knownHosts;
|
||||||
|
this.deviceKeys = deviceKeys;
|
||||||
this.signIn = signIn;
|
this.signIn = signIn;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
this.passphraseProfile = passphraseProfile;
|
this.passphraseProfile = passphraseProfile;
|
||||||
@@ -113,6 +122,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool isBusy;
|
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>
|
/// <remarks>
|
||||||
/// The address <c>dotnet run --project src/DodoSSH.Api</c> actually serves, so the first launch after
|
/// 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
|
/// 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;
|
ServerUrl = profile.ServerUrl;
|
||||||
State = ShellState.Locked;
|
State = ShellState.Locked;
|
||||||
StatusMessage = $"Enrolled against {profile.ServerUrl}.";
|
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)
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -362,34 +386,119 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
|
|
||||||
Passphrase = string.Empty;
|
Passphrase = string.Empty;
|
||||||
|
|
||||||
// Before the vault view model, so the first connection after an unlock already knows which
|
await AdoptAsync(outcome.Session!, cancellationToken).ConfigureAwait(true);
|
||||||
// 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();
|
|
||||||
}).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>
|
/// <summary>
|
||||||
/// Closes the vault and forgets every key it held. Open shells keep running.
|
/// Closes the vault and forgets every key it held. Open shells keep running.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -208,6 +208,16 @@ internal sealed partial class VaultViewModel(
|
|||||||
private Task? autoSyncLoop;
|
private Task? autoSyncLoop;
|
||||||
private bool disposed;
|
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>
|
/// <summary>The hosts to show, unpushed local state included.</summary>
|
||||||
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
|
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,16 @@
|
|||||||
IsVisible="{Binding !IsOnline}" />
|
IsVisible="{Binding !IsOnline}" />
|
||||||
<Button Content="Sign in" Command="{Binding SignInCommand}"
|
<Button Content="Sign in" Command="{Binding SignInCommand}"
|
||||||
IsVisible="{Binding !IsOnline}" />
|
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}" />
|
<Button Content="Sync" Command="{Binding Vault.SyncCommand}" />
|
||||||
<!--
|
<!--
|
||||||
The tooltip carries the policy to the point of action, because the button's name implies
|
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}"
|
<TextBox x:Name="UnlockPassphrase" Text="{Binding Passphrase}"
|
||||||
PlaceholderText="vault passphrase" PasswordChar="•" />
|
PlaceholderText="vault passphrase" PasswordChar="•" />
|
||||||
<Button Content="Unlock" Command="{Binding UnlockCommand}"
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
<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" Text="{Binding StatusMessage}" />
|
||||||
<TextBlock Classes="hint" FontSize="11"
|
<TextBlock Classes="hint" FontSize="11"
|
||||||
Text="This works with no network: the salt and the wrapped key are already on this machine." />
|
Text="This works with no network: the salt and the wrapped key are already on this machine." />
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ public sealed record ClientPaths(string DataDirectory)
|
|||||||
/// <summary>The encrypted local cache.</summary>
|
/// <summary>The encrypted local cache.</summary>
|
||||||
public string CacheFile => Path.Combine(DataDirectory, "cache.db");
|
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>
|
/// <summary>Creates the profile directory if it is not there yet.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Separate from resolving the path, because resolving must never have a side effect: it is read
|
/// 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,4 +15,15 @@
|
|||||||
<ProjectReference Include="../../src/DodoSSH.Client.App/DodoSSH.Client.App.csproj" />
|
<ProjectReference Include="../../src/DodoSSH.Client.App/DodoSSH.Client.App.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!--
|
||||||
|
Shared by source, as DodoSSH.Client.App.Layout.Tests shares the account fakes. The shell's device-key
|
||||||
|
behaviour is about which buttons appear and what happens when one is pressed, and a second
|
||||||
|
implementation of "a keystore that hands its key back" would be a second thing to keep in step with the
|
||||||
|
interface.
|
||||||
|
-->
|
||||||
|
<Compile Include="../DodoSSH.Client.Session.Tests/FakeDeviceKeyStore.cs"
|
||||||
|
Link="Shared/FakeDeviceKeyStore.cs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
using DodoSSH.Client.App.ViewModels;
|
using DodoSSH.Client.App.ViewModels;
|
||||||
using DodoSSH.Client.Session;
|
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.Ssh;
|
||||||
using DodoSSH.Client.Storage;
|
using DodoSSH.Client.Storage;
|
||||||
using DodoSSH.Client.Terminal;
|
using DodoSSH.Client.Terminal;
|
||||||
@@ -43,6 +47,14 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
private ClientCacheFactory caches = null!;
|
private ClientCacheFactory caches = null!;
|
||||||
private TerminalWorkspace workspace = null!;
|
private TerminalWorkspace workspace = null!;
|
||||||
private VaultKnownHostStore knownHosts = null!;
|
private VaultKnownHostStore knownHosts = null!;
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
private FakeDeviceKeyStore deviceKeys = null!;
|
||||||
private MainWindowViewModel shell = null!;
|
private MainWindowViewModel shell = null!;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -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
|
// 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.
|
// this suite already has, so substituting one would only stop the wiring being tested.
|
||||||
knownHosts = new VaultKnownHostStore();
|
knownHosts = new VaultKnownHostStore();
|
||||||
|
deviceKeys = new FakeDeviceKeyStore();
|
||||||
|
|
||||||
// In-memory assets rather than the application's Avalonia-resource provider, which reads the
|
// 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
|
// resource system at construction and needs an initialised toolkit. This is what
|
||||||
@@ -94,6 +107,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
caches,
|
caches,
|
||||||
workspace,
|
workspace,
|
||||||
knownHosts,
|
knownHosts,
|
||||||
|
deviceKeys,
|
||||||
SignInAsync,
|
SignInAsync,
|
||||||
TimeProvider.System,
|
TimeProvider.System,
|
||||||
CheapProfile);
|
CheapProfile);
|
||||||
@@ -276,6 +290,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
caches,
|
caches,
|
||||||
workspace,
|
workspace,
|
||||||
new VaultKnownHostStore(),
|
new VaultKnownHostStore(),
|
||||||
|
new UnavailableDeviceKeyStore(),
|
||||||
(_, _) => throw new InvalidOperationException("The shell went to the network to unlock."),
|
(_, _) => throw new InvalidOperationException("The shell went to the network to unlock."),
|
||||||
TimeProvider.System,
|
TimeProvider.System,
|
||||||
CheapProfile);
|
CheapProfile);
|
||||||
@@ -720,6 +735,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
caches,
|
caches,
|
||||||
workspace,
|
workspace,
|
||||||
new VaultKnownHostStore(),
|
new VaultKnownHostStore(),
|
||||||
|
new UnavailableDeviceKeyStore(),
|
||||||
(_, _) => throw new InvalidOperationException("unreachable"),
|
(_, _) => throw new InvalidOperationException("unreachable"),
|
||||||
TimeProvider.System,
|
TimeProvider.System,
|
||||||
CheapProfile);
|
CheapProfile);
|
||||||
@@ -1251,6 +1267,73 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
|
|
||||||
private Task ReadyToUnlockAsync() => EnrolledAndConfirmedAsync();
|
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()
|
private async Task UnlockedAsync()
|
||||||
{
|
{
|
||||||
await EnrolledAndConfirmedAsync();
|
await EnrolledAndConfirmedAsync();
|
||||||
|
|||||||
@@ -254,49 +254,3 @@ public sealed class DeviceUnlockTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user