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
+151
View File
@@ -1,6 +1,8 @@
using System.Security.Cryptography;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Sync;
using DodoSSH.Crypto;
using NSec.Cryptography;
namespace DodoSSH.Client.Session;
@@ -41,6 +43,31 @@ public enum UnlockStatus
/// <summary>The cached KDF parameters are not something this build can use.</summary>
UnsupportedKdf = 5,
/// <summary>No device key is registered on this machine, so there is nothing to unlock with.</summary>
/// <remarks>
/// The ordinary state for a machine nobody has opted in on, and not an error. A caller that offers
/// device unlock should check this before showing a gesture prompt that cannot lead anywhere.
/// </remarks>
NoDeviceKey = 6,
/// <summary>
/// A device key is registered but this machine would not hand it over.
/// </summary>
/// <remarks>
/// The user declined the gesture, or the platform invalidated the key — a Hello key does not survive a
/// PIN reset. The two are deliberately not distinguished: the remedy is the passphrase either way, and
/// a message naming which one describes the keystore rather than telling the user anything useful.
/// </remarks>
DeviceKeyUnavailable = 7,
/// <summary>The device key was retrieved and did not open the wrap.</summary>
/// <remarks>
/// What a rotated identity looks like from a machine whose device wrap predates it. Distinct from
/// <see cref="DeviceKeyUnavailable"/> because this one will never succeed again — the wrap is for a
/// bundle that no longer exists, and the device has to be registered afresh from an unlocked session.
/// </remarks>
DeviceKeyRejected = 8,
}
/// <summary>The result of an unlock attempt.</summary>
@@ -130,6 +157,130 @@ public sealed class SessionOpener(
}
}
/// <summary>
/// Attempts to open the vault with this machine's device key instead of the passphrase.
/// </summary>
/// <remarks>
/// <para>
/// Touches no network, exactly as the passphrase path does not: the device wrap is cached at
/// registration precisely so the one unlock that saves the user typing is not the one that needs to be
/// online. A gesture on a plane is the case this exists for.
/// </para>
/// <para>
/// Every failure returns rather than throws, and every failure has the same remedy — ask for the
/// passphrase. That is why the caller gets a status and a sentence and not an exception: none of these
/// are exceptional, and a cancelled fingerprint prompt is the most ordinary thing here.
/// </para>
/// </remarks>
public async Task<UnlockOutcome> UnlockWithDeviceAsync(
IDeviceKeyStore deviceKeys,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(deviceKeys);
var profile = await ReadProfileAsync(cancellationToken).ConfigureAwait(false);
if (profile is null)
{
return new UnlockOutcome(
UnlockStatus.NotEnrolled,
null,
"This machine is not enrolled yet. Sign in to a DodoSSH server to set it up.");
}
if (profile.DeviceWrappedPrivateKey is not { } wrap)
{
return new UnlockOutcome(
UnlockStatus.NoDeviceKey,
null,
"This machine has no device key registered. Unlock with your passphrase.");
}
var material = await deviceKeys.TryLoadAsync(cancellationToken).ConfigureAwait(false);
if (material is null)
{
return new UnlockOutcome(
UnlockStatus.DeviceKeyUnavailable,
null,
"This machine did not release its device key. Unlock with your passphrase.");
}
return await OpenWithDeviceAsync(profile, wrap, material, cancellationToken)
.ConfigureAwait(false);
}
/// <remarks>
/// The raw scalar is zeroed before this returns whatever happens. It came out of a keystore into a
/// managed array, which is the one span of its life nothing else is guarding it.
/// </remarks>
private async Task<UnlockOutcome> OpenWithDeviceAsync(
StoredUnlockMaterial profile,
byte[] wrap,
byte[] material,
CancellationToken cancellationToken)
{
UserSecretBundle? bundle;
try
{
bundle = OpenSealedBundle(profile, wrap, material);
}
finally
{
CryptographicOperations.ZeroMemory(material);
}
if (bundle is null)
{
return new UnlockOutcome(
UnlockStatus.DeviceKeyRejected,
null,
"This machine's device key no longer opens the vault. Unlock with your passphrase; the "
+ "device can then be registered again.");
}
var protector = LocalCacheProtector.From(bundle);
try
{
return await BuildSessionAsync(profile, bundle, protector, cancellationToken)
.ConfigureAwait(false);
}
catch
{
protector.Dispose();
bundle.Dispose();
throw;
}
}
/// <remarks>
/// Returns null for a scalar of the wrong length as well as for a wrap that does not open, because a
/// keystore handing back something that is not a key is the same situation from here: this machine
/// cannot unlock and the passphrase can.
/// </remarks>
private static UserSecretBundle? OpenSealedBundle(
StoredUnlockMaterial profile,
byte[] wrap,
byte[] material)
{
if (material.Length != CryptoSpec.SymmetricKeySize)
{
return null;
}
using var deviceKey = Key.Import(
KeyAgreementAlgorithm.X25519,
material,
KeyBlobFormat.RawPrivateKey);
return UserSecretBundle.TryOpenSealed(
deviceKey,
wrap,
DshAad.UserSecretBundle(profile.UserId, profile.KeyGeneration));
}
/// <remarks>
/// The master key lives only inside this method — it opens the bundle and is then done with. The cache
/// protector derives from the bundle rather than from the master key, which is what lets a device or