Unlock with this machine's device key, without a passphrase or a network

The second of ADR 0007's three pieces: the seam a keystore plugs into, the wrap
cached where an offline unlock can reach it, and the unlock path itself. What is
still missing is the keystore — UnavailableDeviceKeyStore is what the application
composes for now, so behaviour is unchanged until piece three lands.

IDeviceKeyStore holds exactly 32 bytes, and only because the cache key moved
first. It would have had to hold the local cache key alongside the X25519 scalar —
a second live secret at rest, going stale on every passphrase change — had
7016ce3 not re-keyed that to the identity bundle. ADeviceUnlock_ReadsTheSameCache
ThePassphraseWrote is the test that ties the two commits together: under the old
derivation this session would have opened the identity and then found its own
cache unreadable.

The wrap is cached at registration rather than fetched at unlock, which is the
whole point. The one unlock path that exists to save the user typing must not be
the one that only works online; a laptop on a plane is precisely where a gesture
should help.

Every way this fails returns a status rather than throwing, because none of them
are exceptional — a cancelled fingerprint prompt is the most ordinary thing in
this file. Three statuses rather than one, because the caller says a different
sentence for each: no device registered (the normal state of a machine nobody
opted in on), the machine would not release the key (declined gesture, or a Hello
key invalidated by a PIN reset — deliberately indistinguishable, since the remedy
does not differ), and the key was released and did not open the wrap (a rotated
identity, which will never succeed again and needs re-registering). A keystore
returning the wrong number of bytes lands in the third rather than crashing the
unlock screen.

The subtle defect this could have shipped is in UnlockStore.Apply. That method
runs on every sign-in from a /me response, which knows nothing about this
machine's keystore — so assigning the device columns unconditionally would delete
the wrap on the next launch, and the user's fingerprint would stop working for no
visible reason and no error anywhere. The columns are therefore written only when
the incoming material carries them, with AttachDeviceAsync and DetachDeviceAsync
as the only paths that set them deliberately. Mutation tested: removing the guard
fails RefreshingTheProfile_DoesNotDiscardTheDeviceWrap and nothing else.

RegisterDeviceAsync lives on VaultSession because sealing the bundle is the one
step only an open session can do, and the session is the bundle's custodian.
Everything else arrives as a parameter, exactly as SyncAsync takes its transport,
so the session still knows nothing about how either the wire or the keystore is
implemented. Its steps are ordered so a failure cannot leave a lie behind: the key
is generated, saved locally, and only then registered with the server. A server
row whose private half was never stored is a device that can never unlock and that
the account claims can — worse than not offering the feature at all — so the write
that could produce it happens after the one that prevents it.

ForgetDeviceAsync is deliberately half a job, and says so. It stops this machine
unlocking without a passphrase, which is what a user turning the feature off means,
but the server's wrap row survives and the account will go on listing a device
that cannot unlock. Deleting it needs an endpoint that does not exist yet. Half
with the gap recorded beats a method whose name promises the other half.

The client cache gained two nullable columns and a migration, generated rather
than hand-written this time.

876 tests green, 10 of them new. Zero warnings, dotnet format clean.

Remaining: the Windows Hello store and the unlock-screen UI. That is where the
Windows target framework lands, and where automated testing stops — a gesture
needs hardware and a person, so the last piece is the one that has to be looked at
rather than asserted.
This commit is contained in:
2026-07-30 13:37:35 +02:00
parent db4a8ed3d3
commit 1faea42b94
10 changed files with 1204 additions and 2 deletions
+85
View File
@@ -0,0 +1,85 @@
namespace DodoSSH.Client.Session;
/// <summary>
/// Where this machine keeps the private half of its device key.
/// </summary>
/// <remarks>
/// <para>
/// An interface because the answer is a platform decision and a security decision, recorded in
/// ADR 0007: on Windows a gesture guards it, and the gesture is the whole value — it is what stops a
/// process running as the user from unlocking the vault silently. Nothing in this assembly should know
/// which gesture, and nothing above it should be able to skip one.
/// </para>
/// <para>
/// <b>Exactly 32 bytes, and only because the cache key moved.</b> This holds the X25519 device private
/// key and nothing else. It would have had to hold the local cache key too — a second live secret at
/// rest, going stale on every passphrase change — had that key not been re-keyed to the identity bundle
/// first. See docs/crypto.md §3.2.
/// </para>
/// <para>
/// Every member may fail or refuse, and refusal is ordinary rather than exceptional: a user can cancel a
/// fingerprint prompt, a Hello key is invalidated when the PIN is reset, and a machine may have no
/// keystore at all. <see cref="TryLoadAsync"/> therefore returns null rather than throwing, and the
/// caller's answer is always the same — ask for the passphrase.
/// </para>
/// </remarks>
public interface IDeviceKeyStore
{
/// <summary>Whether this machine can keep a device key at all.</summary>
/// <remarks>
/// Asked before offering to register one. Registering a device whose private half does not survive
/// the process would put a wrap on the server that nothing can ever open, and make the account's
/// device list claim a capability this machine does not have.
/// </remarks>
ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken);
/// <summary>Stores the device private key, replacing any already held.</summary>
/// <param name="devicePrivateKey">The raw 32-byte X25519 scalar.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask SaveAsync(ReadOnlyMemory<byte> devicePrivateKey, CancellationToken cancellationToken);
/// <summary>
/// Retrieves the device private key, prompting for whatever guards it.
/// </summary>
/// <returns>
/// The raw scalar, or <see langword="null"/> if there is none, the user declined, or the platform
/// has invalidated it. The three are deliberately not distinguished: the caller does the same thing
/// in each case, and a message naming which one would be describing the keystore rather than telling
/// the user anything they can act on.
/// </returns>
ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken);
/// <summary>Discards the stored key.</summary>
/// <remarks>
/// Local only. The server's wrap row outlives this and has to be deleted separately, or the account
/// will go on listing a device that can no longer unlock anything.
/// </remarks>
ValueTask ForgetAsync(CancellationToken cancellationToken);
}
/// <summary>
/// A machine with nowhere to keep a device key.
/// </summary>
/// <remarks>
/// What the application composes until a real keystore is wired up, and the honest answer for a platform
/// that has none. Reports unavailable and holds nothing, so unlock asks for the passphrase exactly as it
/// did before any of this existed — a placeholder that changes no behaviour rather than one that pretends.
/// </remarks>
public sealed class UnavailableDeviceKeyStore : IDeviceKeyStore
{
/// <inheritdoc />
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
ValueTask.FromResult(false);
/// <inheritdoc />
public ValueTask SaveAsync(ReadOnlyMemory<byte> devicePrivateKey, CancellationToken cancellationToken) =>
throw new NotSupportedException(
"This machine has no device key store. Check IsAvailableAsync before offering to register one.");
/// <inheritdoc />
public ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken) =>
ValueTask.FromResult<byte[]?>(null);
/// <inheritdoc />
public ValueTask ForgetAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
}