Files
DodoSSH/tests/DodoSSH.Client.Session.Tests/DeviceUnlockTests.cs
T
jaap-jan f86791e817 Finish revoking a device, instead of half of it
ForgetDeviceAsync stopped this machine unlocking without a passphrase and left
the server's row exactly where it was, so the account went on listing a device
nobody could account for. ADR 0007 recorded that as a deliberate gap needing an
endpoint. This is the endpoint, and the two things that turned up behind it.

DELETE /api/v1/me/devices/{id}. The device row is not the dangerous half: a
kind=device wrap is the user's identity bundle sealed to a key somebody may be
holding, and that is what has to go. It goes on the foreign key's cascade rather
than a second statement, and RevokeDevice_TakesItsWrapWithIt asserts the cascade
rather than trusting the configuration to keep saying so.

Scoped to the caller's own account, which is the only authorisation check there
is. The id is an unguessable v7 GUID, but unguessable is not a permission —
without the scope one user could withdraw another's device key by pasting an id
they saw once, and the victim's next launch would ask for a passphrase with no
explanation. 404 rather than 403 for somebody else's device, so a stranger does
not learn the id exists.

Never refused for being the last device. ADR 0001 makes an enrolled device a
recovery path, so removing the last one does cost the user something — but the
machine being revoked is most likely the one they have just lost, and a server
that argued about it would be refusing the one request that has to work
immediately. The passphrase wrap is untouched either way, which
RevokeDevice_LeavesThePassphraseWrapAlone pins.

--- Two things found on the way ---

Registering twice from one machine left two devices on the account. The server
is idempotent on the public key, but the client generates a fresh key pair every
call and the keystore holds one — so the second registration orphaned a wrap
whose private half had just been overwritten, which is precisely the leftover
this change exists to remove. Registering now withdraws the previous device.
Found by a test that asserted the property and failed.

And the fakes were lying about it. FakeAccountServer's comment claimed the real
service's idempotence while handing back a fresh Guid on every call, which is
invisible until something revokes by id — at which point a test would be
revoking an id the server never issued, and passing. Both fakes now issue one id
per public key and drop the wrap with the device, as the cascade does.

--- Reachable at all ---

ForgetDeviceAsync had exactly one caller and it was a test, so "Stop unlocking
here" now sits in the account bar where "Use Windows Hello here" was. Its own
flag rather than the negation of that one: a machine with no TPM and a machine
that is already registered are both "cannot register", and only the second has
anything to take back.

No confirmation prompt, deliberately. The cost of pressing it by accident is one
passphrase and one re-registration; the cost of a dialog is a moment's
hesitation at the point somebody has realised a machine is in the wrong hands.

Offline it does the local half and says so rather than refusing. Whether this
machine may unlock itself is decided entirely by the local cache and the local
keystore — the unlock path never asks the server — so forgetting here is what
actually revokes, and "you are offline, so this machine will go on unlocking
itself" would be the worst available answer. DeviceRevocation.LocalOnly is what
the interface reports and the status line explains what is left to do.

The local half runs first for the same reason, and the keystore call is the
first thing in the method that can yield: on Windows it raises a consent dialog,
and a dialog wants the thread it was called from. That ordering is currently
load-bearing and shakier than it looks — see the open device-unlock hang.

Four mutations, all caught: dropping the user scope from the server query
(1 test), skipping the stale-device revoke on re-registration (2), skipping the
server call in ForgetDeviceAsync (2), and the earlier version of the client that
never called it at all.

930 tests green across 16 projects, 13 of them new. Zero warnings, format clean.
2026-07-30 17:33:31 +02:00

325 lines
12 KiB
C#

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_SendsThisMachineBackToThePassphraseAndClearsTheAccount()
{
await using (var first = await UnlockAsync())
{
await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token);
}
server.RegisteredDevices.Count.ShouldBe(1);
await using (var second = (await Opener().UnlockWithDeviceAsync(deviceKeys, Token)).Session!)
{
var revocation = await second.ForgetDeviceAsync(server, deviceKeys, Token);
revocation.ShouldBe(DeviceRevocation.Complete);
}
var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token);
outcome.Status.ShouldBe(UnlockStatus.NoDeviceKey);
// The half that used to be left behind. A kind=device wrap is the identity bundle sealed to a key
// somebody may still hold, so a revocation that only cleared this machine left the dangerous part
// exactly where it was.
server.RegisteredDevices.ShouldBeEmpty();
// 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);
}
/// <remarks>
/// Revoking is most wanted at the moment a machine is lost, and being offline is no reason to leave it
/// able to let itself in. The local half is the half that decides whether this machine may unlock — the
/// unlock path never asks the server — so doing it anyway is strictly better than refusing, provided the
/// caller is told the account has not been told.
/// </remarks>
[Fact]
public async Task ForgettingTheDeviceOffline_StillStopsThisMachineAndSaysWhatWasNotDone()
{
await using (var first = await UnlockAsync())
{
await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token);
}
await using (var second = (await Opener().UnlockWithDeviceAsync(deviceKeys, Token)).Session!)
{
var revocation = await second.ForgetDeviceAsync(api: null, deviceKeys, Token);
revocation.ShouldBe(DeviceRevocation.LocalOnly);
}
(await Opener().UnlockWithDeviceAsync(deviceKeys, Token)).Status
.ShouldBe(UnlockStatus.NoDeviceKey);
server.RegisteredDevices.Count.ShouldBe(1, "nothing reached the server, and it must not pretend");
}
[Fact]
public async Task ForgettingADeviceThatWasNeverRegistered_SaysSoAndAsksTheServerNothing()
{
await using var session = await UnlockAsync();
var revocation = await session.ForgetDeviceAsync(server, deviceKeys, Token);
revocation.ShouldBe(DeviceRevocation.NothingRegistered);
}
/// <remarks>
/// Registering twice from one machine must not leave two revocable devices behind, which is a property of
/// the id the server issues rather than of the client: the real service is idempotent on the public key
/// and returns the id it already has. Pinned here because a fake that invented a fresh id per call would
/// make every revocation test pass while revoking something that was never registered.
/// </remarks>
[Fact]
public async Task RegisteringTwice_KeepsOneDeviceAndOneIdToRevoke()
{
await using var session = await UnlockAsync();
await session.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token);
await session.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token);
server.RegisteredDevices.Count.ShouldBe(1);
var revocation = await session.ForgetDeviceAsync(server, deviceKeys, Token);
revocation.ShouldBe(DeviceRevocation.Complete);
server.RegisteredDevices.ShouldBeEmpty();
}
[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>