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
@@ -0,0 +1,302 @@
using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Session.Tests;
/// <summary>
/// Unlocking with this machine's device key instead of the passphrase.
/// </summary>
/// <remarks>
/// <para>
/// The headline test is the offline one: register a device, close the vault, and open it again with no
/// server and no passphrase. That is the whole feature, and it is the case a design that fetched the wrap
/// at unlock time would have failed.
/// </para>
/// <para>
/// The rest are the ways it is allowed to fail, and every one of them has the same remedy — ask for the
/// passphrase. They are separate statuses rather than one because the caller shows a different sentence
/// for a machine that was never registered than for a gesture somebody declined.
/// </para>
/// </remarks>
public sealed class DeviceUnlockTests : IAsyncLifetime
{
private const string Passphrase = "correct horse battery staple";
private const string ServerUrl = "https://dodossh.example";
private static readonly Argon2Profile CheapProfile =
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
private readonly FakeAccountServer server = new();
private readonly StubKeyBinding keyBinding = new();
private readonly FakeDeviceKeyStore deviceKeys = new();
private ClientCacheFactory caches = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public async ValueTask InitializeAsync()
{
caches = ClientCacheFactory.ForMemory($"device-{Guid.CreateVersion7():N}");
await caches.MigrateAsync(Token);
await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
caches.Dispose();
return ValueTask.CompletedTask;
}
[Fact]
public async Task ARegisteredDevice_UnlocksWithNoPassphraseAndNoNetwork()
{
// The feature. Everything else in this file is about the ways it declines to happen.
await using (var first = await UnlockAsync())
{
(await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token)).ShouldBeTrue();
}
var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token);
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
await using var session = outcome.Session!;
session.Profile.UserId.ShouldBe(server.UserId);
session.ActiveVaultId.ShouldNotBe(Guid.Empty);
}
[Fact]
public async Task ADeviceUnlock_ReadsTheSameCacheThePassphraseWrote()
{
// Why the cache key had to move off the master key. A device unlock never computes one, so under the
// old derivation this session would have opened the identity and then found its own cache
// unreadable — see docs/crypto.md §3.2.
Guid hostId;
await using (var first = await UnlockAsync())
{
await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token);
hostId = await first.Hosts.CreateAsync(
first.ActiveVaultId,
new HostSecret { Label = "db", Hostname = "db.internal", Username = "deploy" },
Token);
}
var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token);
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
await using var session = outcome.Session!;
var hosts = await session.Hosts.ListAsync(session.ActiveVaultId, Token);
hosts.Items.ShouldContain(host => host.EntityId == hostId);
}
[Fact]
public async Task WithNoDeviceRegistered_ItSaysSoRatherThanFailing()
{
// The ordinary state of a machine nobody has opted in on. A caller checks this before showing a
// gesture prompt that could not lead anywhere.
var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token);
outcome.Status.ShouldBe(UnlockStatus.NoDeviceKey);
outcome.Session.ShouldBeNull();
}
[Fact]
public async Task WhenTheGestureIsDeclined_ItAsksForThePassphrase()
{
await using (var first = await UnlockAsync())
{
await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token);
}
// What a cancelled fingerprint prompt looks like from here, and what a Hello key invalidated by a
// PIN reset looks like too. Deliberately the same status: the remedy does not differ.
deviceKeys.Decline = true;
var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token);
outcome.Status.ShouldBe(UnlockStatus.DeviceKeyUnavailable);
outcome.Session.ShouldBeNull();
}
[Fact]
public async Task WhenTheStoredKeyDoesNotOpenTheWrap_ItIsRejectedRatherThanRetried()
{
await using (var first = await UnlockAsync())
{
await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token);
}
// A different key of the right length: what a rotated identity leaves behind on a machine whose
// wrap predates it. Distinct from a declined gesture because this one will never succeed again.
deviceKeys.Overwrite(new byte[CryptoSpec.SymmetricKeySize]);
var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token);
outcome.Status.ShouldBe(UnlockStatus.DeviceKeyRejected);
outcome.Session.ShouldBeNull();
}
[Fact]
public async Task WhenTheKeystoreReturnsSomethingThatIsNotAKey_ItIsRefusedNotThrown()
{
// A keystore handing back the wrong number of bytes is a broken keystore, and the answer is still a
// passphrase prompt rather than a crash on the unlock screen.
await using (var first = await UnlockAsync())
{
await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token);
}
deviceKeys.Overwrite([1, 2, 3]);
var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token);
outcome.Status.ShouldBe(UnlockStatus.DeviceKeyRejected);
}
[Fact]
public async Task OnAMachineWithNoKeystore_RegisteringDeclinesAndRegistersNothing()
{
// Registering a device whose private half does not survive the process would put a wrap on the
// server that nothing can open, and make the account claim a capability this machine lacks.
await using var session = await UnlockAsync();
var registered = await session.RegisterDeviceAsync(
server, new UnavailableDeviceKeyStore(), "this laptop", Token);
registered.ShouldBeFalse();
server.RegisteredDevices.ShouldBeEmpty();
}
[Fact]
public async Task ForgettingTheDevice_SendsThisMachineBackToThePassphrase()
{
await using (var first = await UnlockAsync())
{
await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token);
}
await using (var second = (await Opener().UnlockWithDeviceAsync(deviceKeys, Token)).Session!)
{
await second.ForgetDeviceAsync(deviceKeys, Token);
}
var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token);
outcome.Status.ShouldBe(UnlockStatus.NoDeviceKey);
// And the passphrase still works, which is the property that makes forgetting safe to offer.
await using var byPassphrase = await UnlockAsync();
byPassphrase.ActiveVaultId.ShouldNotBe(Guid.Empty);
}
[Fact]
public async Task RefreshingTheProfile_DoesNotDiscardTheDeviceWrap()
{
// The trap in UnlockStore.Apply. /me is re-read on every sign-in and knows nothing about this
// machine's keystore, so writing its device columns unconditionally would delete the wrap on the
// next launch — and the user's fingerprint would stop working for no visible reason.
await using (var first = await UnlockAsync())
{
await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token);
}
await Provisioner().RefreshAsync(ServerUrl, Token);
var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token);
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
await outcome.Session!.DisposeAsync();
}
[Fact]
public async Task TheWrapReachesTheServerAndTheKeyDoesNot()
{
// The division the whole design rests on: the server stores a sealed bundle it cannot open, and the
// private half never leaves this machine.
await using var session = await UnlockAsync();
await session.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token);
server.RegisteredDevices.Count.ShouldBe(1);
var stored = deviceKeys.Peek().ShouldNotBeNull();
stored.Length.ShouldBe(CryptoSpec.SymmetricKeySize);
// Nothing the server holds contains the private scalar.
foreach (var wrap in server.RegisteredDevices.Values)
{
Convert.ToHexString(wrap).ShouldNotContain(
Convert.ToHexString(stored), Case.Insensitive);
}
}
// ---- Helpers ----
private SessionOpener Opener() => new(caches, TimeProvider.System);
private AccountProvisioner Provisioner() =>
new(server, keyBinding, caches, TimeProvider.System, CheapProfile);
private async Task<VaultSession> UnlockAsync()
{
var outcome = await Opener().UnlockAsync(Passphrase, Token);
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
return outcome.Session!;
}
}
/// <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;
}
}