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.
This commit is contained in:
2026-07-30 17:33:31 +02:00
parent d17a60e7c3
commit f86791e817
13 changed files with 626 additions and 27 deletions
+14 -3
View File
@@ -148,6 +148,17 @@ would have become false under DPAPI alone. A gesture is still something the atta
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
by construction. by construction.
- **Revocation must delete the server row**, and un-enrolling the machine in front of the user must not - **Revocation deletes the server row**, through `DELETE /api/v1/me/devices/{id}`, and the wrap goes with it
be able to lock them out: [ADR 0001](0001-e2ee-trust-model.md) makes an enrolled device a recovery on the foreign key's cascade. The device row is not the dangerous half: a `kind=device` wrap left behind is
path, so it is now load-bearing for more than convenience. the user's identity bundle still sealed to a key somebody may hold. It is never refused for being the last
device — [ADR 0001](0001-e2ee-trust-model.md) makes an enrolled device a recovery path, so removing the
last one does cost something, but the machine being revoked is most likely the one just lost and a server
that argued would be refusing the one request that has to work immediately. The passphrase wrap is
untouched, so this can never lock anyone out.
- **Offline revocation does the local half and says so.** What decides whether a machine may unlock itself is
entirely local — the unlock path never asks the server — so the useful half always happens, and only the
account being told can be out of reach.
- **A machine is a device, so registering again replaces rather than adds.** The server is idempotent on the
public key, but the client generates a fresh key pair each time and the keystore holds one, so a second
registration left the account listing a device whose private half had just been overwritten — an orphaned
wrap of exactly the kind revocation exists to remove. Registering now withdraws the previous device.
@@ -81,6 +81,46 @@ internal sealed class DeviceService(DodoDbContext database, TimeProvider clock)
return new RegisterDeviceResponse(registered.Id, now); return new RegisterDeviceResponse(registered.Id, now);
} }
/// <summary>
/// Removes a device and the wrap that let it unlock.
/// </summary>
/// <returns>Whether there was one to remove.</returns>
/// <remarks>
/// <para>
/// Scoped to the caller's own account, which is the whole authorisation check: the id is a v7 GUID and
/// unguessable, but "unguessable" is not a permission, and a user must not be able to revoke somebody
/// else's laptop by pasting an id they saw once.
/// </para>
/// <para>
/// The wrap goes with it through the foreign key's cascade rather than a second statement, and that is
/// worth naming because the device row is not the dangerous half. A device nobody can use is untidy; a
/// <see cref="UserKeyWrapKind.Device"/> wrap left behind is the user's identity bundle still sealed to a
/// key somebody may hold. <c>RevokingADevice_TakesItsWrapWithIt</c> asserts the cascade rather than
/// trusting the configuration to keep saying so.
/// </para>
/// <para>
/// 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 somebody is revoking 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, so this can never
/// lock anyone out of their own vault.
/// </para>
/// </remarks>
internal async Task<bool> RevokeAsync(
UserAccount user,
Guid deviceId,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(user);
var removed = await database.Devices
.Where(device => device.Id == deviceId && device.UserId == user.Id)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
return removed > 0;
}
/// <summary>Rounds a timestamp down to what the database can actually hold.</summary> /// <summary>Rounds a timestamp down to what the database can actually hold.</summary>
/// <remarks> /// <remarks>
/// <see cref="TimeProvider"/> reports 100-nanosecond ticks and PostgreSQL's <c>timestamp with time /// <see cref="TimeProvider"/> reports 100-nanosecond ticks and PostgreSQL's <c>timestamp with time
@@ -39,6 +39,14 @@ internal static class IdentityEndpoints
.WithName("RegisterDevice") .WithName("RegisterDevice")
.WithSummary("Registers a device key so this machine can unlock without the passphrase."); .WithSummary("Registers a device key so this machine can unlock without the passphrase.");
// On the group's ordinary policy, unlike registering. Registering needs a bundle to seal, so
// demanding enrollment says something true; revoking needs nothing but the account, and a user whose
// enrollment state is somehow in doubt is exactly who should still be able to withdraw a laptop they
// have lost. Not enrolled means no devices, which this answers as 404 and no harm done.
group.MapDelete("/devices/{deviceId:guid}", RevokeDeviceAsync)
.WithName("RevokeDevice")
.WithSummary("Withdraws a device key, so that machine can no longer unlock without the passphrase.");
return app; return app;
} }
@@ -125,6 +133,25 @@ internal static class IdentityEndpoints
} }
} }
/// <remarks>
/// 404 for a device that is not there, rather than a bland 204. A revocation is one of the few calls
/// where succeeding on a typo would be a real disservice — "revoked" is what the user reads, and reading
/// it about the wrong id is worse than being told to look again. Clients that are only driving towards
/// "this machine cannot unlock" can treat 404 as having arrived, which is what the desktop client does.
/// </remarks>
private static async Task<Results<NoContent, NotFound>> RevokeDeviceAsync(
Guid deviceId,
ICurrentUserContext currentUser,
DeviceService devices,
CancellationToken cancellationToken)
{
var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
var revoked = await devices.RevokeAsync(user, deviceId, cancellationToken).ConfigureAwait(false);
return revoked ? TypedResults.NoContent() : TypedResults.NotFound();
}
private static ProblemHttpResult Problem(int statusCode, string code, string detail) => private static ProblemHttpResult Problem(int statusCode, string code, string detail) =>
TypedResults.Problem( TypedResults.Problem(
detail: detail, detail: detail,
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Net; using System.Net;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
@@ -45,6 +46,16 @@ public interface IAccountApi
Task<RegisterDeviceResponse> RegisterDeviceAsync( Task<RegisterDeviceResponse> RegisterDeviceAsync(
RegisterDeviceRequest request, RegisterDeviceRequest request,
CancellationToken cancellationToken); CancellationToken cancellationToken);
/// <summary>
/// Withdraws a device key, so that machine can no longer unlock without the passphrase.
/// </summary>
/// <returns>
/// Whether the account had that device. False means it did not, which a caller withdrawing its own
/// device should treat as having arrived rather than as a failure — another machine may have revoked it
/// first, and the goal state is the same either way.
/// </returns>
Task<bool> RevokeDeviceAsync(Guid deviceId, CancellationToken cancellationToken);
} }
/// <summary> /// <summary>
@@ -157,6 +168,12 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
DodoSshJsonContext.Default.RegisterDeviceResponse, DodoSshJsonContext.Default.RegisterDeviceResponse,
cancellationToken); cancellationToken);
/// <inheritdoc />
public Task<bool> RevokeDeviceAsync(Guid deviceId, CancellationToken cancellationToken) =>
DeleteAsync(
string.Create(CultureInfo.InvariantCulture, $"{DevicesPath}/{deviceId}"),
cancellationToken);
/// <summary>Reads vault changes after a cursor.</summary> /// <summary>Reads vault changes after a cursor.</summary>
/// <remarks> /// <remarks>
/// A POST despite being a read: the filters live in the body, cursors are opaque, and no caching is /// A POST despite being a read: the filters live in the body, cursors are opaque, and no caching is
@@ -216,6 +233,41 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
return await SendCoreAsync(request, typeInfo, cancellationToken).ConfigureAwait(false); return await SendCoreAsync(request, typeInfo, cancellationToken).ConfigureAwait(false);
} }
/// <summary>
/// Sends a delete whose success carries no body.
/// </summary>
/// <returns>True for a 2xx, false for a 404; anything else throws.</returns>
/// <remarks>
/// Its own path rather than <see cref="SendAsync{T}"/> with some empty response type, because the two
/// disagree about what a missing body means. Everywhere else a 200 with nothing in it is a server bug
/// worth an exception; here it is the answer.
/// </remarks>
private async Task<bool> DeleteAsync(string path, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Delete, path);
var token = await tokens.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return false;
}
if (!response.IsSuccessStatusCode)
{
var body = await response.Content
.ReadAsStringAsync(cancellationToken)
.ConfigureAwait(false);
throw DodoSshApiException.FromResponse(response.StatusCode, body);
}
return true;
}
private async Task<T> SendCoreAsync<T>( private async Task<T> SendCoreAsync<T>(
HttpRequestMessage request, HttpRequestMessage request,
System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> typeInfo, System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> typeInfo,
@@ -130,6 +130,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[ObservableProperty] [ObservableProperty]
private bool canRegisterDevice; private bool canRegisterDevice;
/// <summary>Whether this machine has a device key to withdraw.</summary>
[ObservableProperty]
private bool canForgetDevice;
/// <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
@@ -453,10 +457,59 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
} }
CanRegisterDevice = false; CanRegisterDevice = false;
CanForgetDevice = true;
StatusMessage = $"'{name}' can now unlock without your passphrase."; StatusMessage = $"'{name}' can now unlock without your passphrase.";
}).ConfigureAwait(true); }).ConfigureAwait(true);
} }
/// <summary>
/// Withdraws this machine's device key, here and on the account.
/// </summary>
/// <remarks>
/// <para>
/// Offered without a confirmation prompt, which is deliberate. The cost of pressing it by accident is one
/// passphrase and one re-registration; the cost of a confirmation dialog is a moment's hesitation at the
/// point somebody has realised a machine is in the wrong hands. Reversible and urgent beats guarded.
/// </para>
/// <para>
/// Works offline, and says so. What decides whether this machine may unlock itself is entirely local, so
/// the useful half always happens — the account being told is the half that can be out of reach.
/// </para>
/// </remarks>
[RelayCommand]
private async Task ForgetDeviceAsync(CancellationToken cancellationToken)
{
if (Vault is not { } vault)
{
return;
}
await RunAsync(
"Waiting for Windows…",
async () =>
{
var revocation = await vault.Session
.ForgetDeviceAsync(connection?.Account, deviceKeys, cancellationToken)
.ConfigureAwait(true);
CanForgetDevice = false;
// Not re-offered here even though it is now true, because registering probes the TPM and
// this is not the moment to do it: somebody who has just withdrawn a device is not about to
// add one back, and the offer reappears on the next unlock.
StatusMessage = revocation switch
{
DeviceRevocation.Complete =>
"This machine no longer unlocks without your passphrase, and the account no longer "
+ "lists it.",
DeviceRevocation.LocalOnly =>
"This machine no longer unlocks without your passphrase. You are offline, so the "
+ "account still lists it — sign in and withdraw it again to finish.",
_ => "There was no device key on this machine.",
};
}).ConfigureAwait(true);
}
/// <summary> /// <summary>
/// Takes ownership of a freshly opened session, whichever door opened it. /// Takes ownership of a freshly opened session, whichever door opened it.
/// </summary> /// </summary>
@@ -491,6 +544,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
CanRegisterDevice = session.Profile.DeviceWrappedPrivateKey is null CanRegisterDevice = session.Profile.DeviceWrappedPrivateKey is null
&& await deviceKeys.IsAvailableAsync(cancellationToken).ConfigureAwait(true); && await deviceKeys.IsAvailableAsync(cancellationToken).ConfigureAwait(true);
// The other side of the same fact, and it needs its own flag rather than the negation of that one:
// "not offered because this machine has no TPM" and "not offered because it is already registered"
// are both !CanRegisterDevice, and only the second has anything to withdraw.
CanForgetDevice = session.Profile.DeviceWrappedPrivateKey is not null;
await Vault.LoadAsync(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 // After the first load, so the list is on screen before anything talks to a server. The loop is
@@ -60,6 +60,15 @@
IsEnabled="{Binding !IsBusy}" IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanRegisterDevice}" 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." /> 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." />
<!--
The withdrawal, in the place the offer 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.
-->
<Button Content="Stop unlocking here" Command="{Binding ForgetDeviceCommand}"
IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanForgetDevice}"
ToolTip.Tip="Withdraws this machine's device key, here and from your account, so it goes back to asking for your passphrase. Do this to a machine you have lost." />
<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
+26
View File
@@ -1,5 +1,31 @@
namespace DodoSSH.Client.Session; namespace DodoSSH.Client.Session;
/// <summary>
/// How far withdrawing a device key got.
/// </summary>
/// <remarks>
/// Three outcomes rather than a boolean, because the middle one is a state the user has to be told about
/// and can act on. The local half always happens; only the server half can be out of reach.
/// </remarks>
public enum DeviceRevocation
{
/// <summary>There was no device key here to withdraw.</summary>
NothingRegistered,
/// <summary>
/// This machine can no longer unlock itself, but the account still lists the device.
/// </summary>
/// <remarks>
/// Offline. The wrap on the server is the user's bundle sealed to a key this machine has now destroyed,
/// so nothing can open it — but it is still a row that should not be there, and a reinstall would pull
/// it down again and offer a device unlock that cannot work.
/// </remarks>
LocalOnly,
/// <summary>Gone from this machine and from the account.</summary>
Complete,
}
/// <summary> /// <summary>
/// Where this machine keeps the private half of its device key. /// Where this machine keeps the private half of its device key.
/// </summary> /// </summary>
+66 -8
View File
@@ -206,10 +206,24 @@ public sealed class VaultSession : IAsyncDisposable
CryptographicOperations.ZeroMemory(privateKey); CryptographicOperations.ZeroMemory(privateKey);
} }
// Read after the keystore write, not before, so that write stays the first thing in this method that
// can yield. On Windows it raises a consent dialog, and a dialog wants the thread it was called from.
var previousDeviceId = (await Unlock.ReadAsync(cancellationToken).ConfigureAwait(false))?.DeviceId;
var registered = await api var registered = await api
.RegisterDeviceAsync(new RegisterDeviceRequest(deviceName, publicKey, wrap), cancellationToken) .RegisterDeviceAsync(new RegisterDeviceRequest(deviceName, publicKey, wrap), cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
// A machine is a device, so registering again replaces rather than adds. The server is idempotent on
// the public key, but this generates a fresh key pair every time and the keystore holds one — so a
// second registration would leave the account listing a device whose private half has just been
// overwritten. That row is not merely untidy: its kind=device wrap is the identity bundle sealed to a
// key that no longer exists anywhere, which is precisely the leftover revocation exists to remove.
if (previousDeviceId is { } stale && stale != registered.DeviceId)
{
await api.RevokeDeviceAsync(stale, cancellationToken).ConfigureAwait(false);
}
await Unlock.AttachDeviceAsync(registered.DeviceId, wrap, cancellationToken) await Unlock.AttachDeviceAsync(registered.DeviceId, wrap, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
@@ -217,23 +231,67 @@ public sealed class VaultSession : IAsyncDisposable
} }
/// <summary> /// <summary>
/// Withdraws this machine's device key, locally. /// Withdraws this machine's device key, here and on the account.
/// </summary> /// </summary>
/// <param name="api">The server, or null when there is none to reach.</param>
/// <param name="deviceKeys">This machine's keystore.</param>
/// <param name="cancellationToken">Cancellation.</param>
/// <returns>How far the withdrawal got.</returns>
/// <remarks> /// <remarks>
/// Deliberately incomplete, and the gap is recorded rather than papered over: the server's wrap row /// <para>
/// survives this, so the account will go on listing a device that can no longer unlock. Deleting it /// <b>The local half first, and it is the half that matters.</b> Whether this machine may unlock without
/// needs an endpoint that does not exist yet. Until then the honest half is this one — the machine stops /// a passphrase is decided entirely by what is in the local cache and the local keystore — the unlock
/// being able to unlock without a passphrase, which is what a user asking to turn it off means. /// path never asks the server — so forgetting here is what actually revokes. Doing it first also means a
/// server call that fails cannot leave the machine still able to let itself in.
/// </para>
/// <para>
/// The server row is not bookkeeping, though, which is why this no longer stops at the local half. A
/// <c>kind=device</c> wrap is the user's identity bundle sealed to a key that may be in somebody else's
/// laptop; leaving it there means a machine that is wiped and reinstalled can pull the wrap down again,
/// and it means the account goes on listing a device nobody can account for.
/// </para>
/// <para>
/// Offline still does the local half and says so, rather than refusing. Somebody revoking a device
/// usually has a reason to want it gone <em>now</em>, and "you are offline, so this machine will go on
/// unlocking itself" is the worst of the available answers. <see cref="DeviceRevocation.LocalOnly"/> is
/// what the interface reports, and it is a state the user can act on by trying again online.
/// </para>
/// <para>
/// The device id is read from the store rather than from <see cref="Profile"/>, which is a snapshot taken
/// when the session opened and does not know about a device registered since.
/// </para>
/// </remarks> /// </remarks>
public async Task ForgetDeviceAsync(IDeviceKeyStore deviceKeys, CancellationToken cancellationToken) public async Task<DeviceRevocation> ForgetDeviceAsync(
IAccountApi? api,
IDeviceKeyStore deviceKeys,
CancellationToken cancellationToken)
{ {
ObjectDisposedException.ThrowIf(disposed, this); ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(deviceKeys); ArgumentNullException.ThrowIfNull(deviceKeys);
// Before any await that could yield, because on Windows this reaches a consent dialog and a dialog
// needs the thread it was called from to be one that pumps messages. See WindowsDeviceKeyStore.
await deviceKeys.ForgetAsync(cancellationToken).ConfigureAwait(false); await deviceKeys.ForgetAsync(cancellationToken).ConfigureAwait(false);
await Unlock.DetachDeviceAsync(cancellationToken) var stored = await Unlock.ReadAsync(cancellationToken).ConfigureAwait(false);
.ConfigureAwait(false);
await Unlock.DetachDeviceAsync(cancellationToken).ConfigureAwait(false);
if (stored?.DeviceId is not { } deviceId)
{
return DeviceRevocation.NothingRegistered;
}
if (api is null)
{
return DeviceRevocation.LocalOnly;
}
// A device the account does not have is the state this was aiming at, so a false answer is an
// arrival rather than a failure — another machine may have revoked it first.
await api.RevokeDeviceAsync(deviceId, cancellationToken).ConfigureAwait(false);
return DeviceRevocation.Complete;
} }
/// <summary> /// <summary>
@@ -957,6 +957,114 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
ProblemCodes.InvalidDeviceRegistration); ProblemCodes.InvalidDeviceRegistration);
} }
/// <remarks>
/// The wrap is the half that matters. A device row nobody can use is untidy; a <c>kind=device</c> wrap
/// left behind is the user's identity bundle still sealed to a key somebody may be holding. This asserts
/// the foreign key's cascade rather than trusting the configuration to go on saying so.
/// </remarks>
[Fact]
public async Task RevokeDevice_TakesItsWrapWithIt()
{
using var enrollment = NewEnrollment();
var client = enrollment.CreateClient(fixture);
await EnrollAsync(client, enrollment.Build());
var registered = await RegisterDeviceAsync(
client, new RegisterDeviceRequest("a laptop", DeviceKey(), Wrap()));
var response = await client.DeleteAsync(
new Uri($"{DevicesUrl}/{registered.DeviceId}", UriKind.Relative));
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
await using var scope = fixture.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
(await database.Devices.CountAsync(d => d.Id == registered.DeviceId)).ShouldBe(0);
(await database.UserKeyWraps.CountAsync(w => w.DeviceId == registered.DeviceId)).ShouldBe(0);
}
/// <remarks>
/// The passphrase wrap is what makes revocation safe to offer at all: withdrawing every device must
/// never be able to lock somebody out of their own vault, so this checks the one row that guarantees it
/// is still there afterwards.
/// </remarks>
[Fact]
public async Task RevokeDevice_LeavesThePassphraseWrapAlone()
{
using var enrollment = NewEnrollment();
var client = enrollment.CreateClient(fixture);
await EnrollAsync(client, enrollment.Build());
var registered = await RegisterDeviceAsync(
client, new RegisterDeviceRequest("a laptop", DeviceKey(), Wrap()));
await client.DeleteAsync(new Uri($"{DevicesUrl}/{registered.DeviceId}", UriKind.Relative));
var me = await ReadMeAsync(client);
me.EnrollmentRequired.ShouldBeFalse();
me.WrappedPrivateKey.ShouldNotBeNull();
}
[Fact]
public async Task RevokeDevice_ThatIsNotThere_Is404()
{
// Rather than a bland 204. "Revoked" is what the user reads, and reading it about the wrong id is
// worse than being told to look again — a client driving towards "this machine cannot unlock" can
// treat 404 as having arrived, and the desktop one does.
using var enrollment = NewEnrollment();
var client = enrollment.CreateClient(fixture);
await EnrollAsync(client, enrollment.Build());
var response = await client.DeleteAsync(
new Uri($"{DevicesUrl}/{Guid.CreateVersion7()}", UriKind.Relative));
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
/// <remarks>
/// The authorisation check, and the only one there is: the id is an unguessable v7 GUID, but unguessable
/// is not a permission. Without the user scope on the query, one account could withdraw another's device
/// key by pasting an id it saw once — and the victim's next launch would ask for a passphrase with no
/// explanation.
/// </remarks>
[Fact]
public async Task RevokeDevice_BelongingToSomebodyElse_Is404AndChangesNothing()
{
using var mine = NewEnrollment();
var myClient = mine.CreateClient(fixture);
await EnrollAsync(myClient, mine.Build());
var registered = await RegisterDeviceAsync(
myClient, new RegisterDeviceRequest("my laptop", DeviceKey(), Wrap()));
using var theirs = NewEnrollment();
var theirClient = theirs.CreateClient(fixture);
await EnrollAsync(theirClient, theirs.Build());
var response = await theirClient.DeleteAsync(
new Uri($"{DevicesUrl}/{registered.DeviceId}", UriKind.Relative));
// 404 rather than 403, because a stranger must not learn that the id exists.
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
await using var scope = fixture.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
(await database.Devices.CountAsync(d => d.Id == registered.DeviceId)).ShouldBe(1);
(await database.UserKeyWraps.CountAsync(w => w.DeviceId == registered.DeviceId)).ShouldBe(1);
}
[Fact]
public async Task RevokeDevice_WithoutAToken_Is401()
{
var response = await fixture.CreateClient().DeleteAsync(
new Uri($"{DevicesUrl}/{Guid.CreateVersion7()}", UriKind.Relative));
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
}
[Fact] [Fact]
public async Task RegisterDevice_WithABlankName_IsRejected() public async Task RegisterDevice_WithABlankName_IsRejected()
{ {
@@ -46,6 +46,9 @@ internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKe
/// <summary>Device wraps registered after enrollment, keyed on the device public key.</summary> /// <summary>Device wraps registered after enrollment, keyed on the device public key.</summary>
internal Dictionary<string, byte[]> RegisteredDevices { get; } = new(StringComparer.Ordinal); internal Dictionary<string, byte[]> RegisteredDevices { get; } = new(StringComparer.Ordinal);
/// <summary>The id issued for each registered public key, so revocation has something to name.</summary>
private readonly Dictionary<string, Guid> deviceIds = new(StringComparer.Ordinal);
/// <summary>When set, the next sign-in throws — how an unreachable server is exercised.</summary> /// <summary>When set, the next sign-in throws — how an unreachable server is exercised.</summary>
internal Exception? SignInFailure { get; set; } internal Exception? SignInFailure { get; set; }
@@ -150,11 +153,37 @@ internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKe
"This account has no identity key yet."); "This account has no identity key yet.");
} }
RegisteredDevices[Convert.ToHexString(request.PublicKey)] = request.WrappedPrivateKey; var key = Convert.ToHexString(request.PublicKey);
return Task.FromResult(new RegisterDeviceResponse( RegisteredDevices[key] = request.WrappedPrivateKey;
DeviceId: Guid.CreateVersion7(),
EnrolledAt: DateTimeOffset.UnixEpoch)); // One id per public key, as the real service issues, so a revocation can name the device that was
// actually registered rather than one this fake invented on the way past.
if (!deviceIds.TryGetValue(key, out var deviceId))
{
deviceId = Guid.CreateVersion7();
deviceIds[key] = deviceId;
}
return Task.FromResult(new RegisterDeviceResponse(deviceId, DateTimeOffset.UnixEpoch));
}
/// <inheritdoc />
public Task<bool> RevokeDeviceAsync(Guid deviceId, CancellationToken cancellationToken)
{
var key = deviceIds.FirstOrDefault(entry => entry.Value == deviceId).Key;
if (key is null)
{
return Task.FromResult(false);
}
deviceIds.Remove(key);
// With its wrap, as the foreign key's cascade does on the real server.
RegisteredDevices.Remove(key);
return Task.FromResult(true);
} }
// ---- Sync ---- // ---- Sync ----
@@ -1809,6 +1809,93 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.Passphrase.ShouldBeEmpty("nothing was typed"); shell.Passphrase.ShouldBeEmpty("nothing was typed");
} }
[Fact]
public async Task WithdrawingTheDevice_SendsThisMachineBackToThePassphraseAndClearsTheAccount()
{
// The button that makes revocation reachable at all. Until it existed, ForgetDeviceAsync had one
// caller and that caller was a test.
await UnlockedAsync();
await shell.RegisterDeviceCommand.ExecuteAsync(null);
shell.CanForgetDevice.ShouldBeTrue("there is a device key here now");
await shell.ForgetDeviceCommand.ExecuteAsync(null);
shell.CanForgetDevice.ShouldBeFalse("the offer is spent");
server.RegisteredDevices.ShouldBeEmpty("the account must not go on listing it");
await shell.LockCommand.ExecuteAsync(null);
await shell.StartAsync(Token);
shell.CanUnlockWithDevice.ShouldBeFalse("there is nothing left to unlock with");
// And the passphrase still opens it, which is what makes withdrawing safe to offer without a
// confirmation prompt.
shell.Passphrase = Passphrase;
await shell.UnlockCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
}
/// <remarks>
/// The offer and the withdrawal are two flags rather than one and its negation, and this is why: on a
/// machine with no keystore both are false, and a single flag would have made "cannot register" mean
/// "has something to withdraw".
/// </remarks>
[Fact]
public async Task OnAMachineWithNoKeystore_ThereIsNothingToWithdrawEither()
{
deviceKeys.IsAvailable = false;
await UnlockedAsync();
shell.CanRegisterDevice.ShouldBeFalse();
shell.CanForgetDevice.ShouldBeFalse();
}
[Fact]
public async Task WithdrawingTheDeviceOffline_StopsThisMachineAndSaysTheAccountStillListsIt()
{
// Somebody who has just realised a machine is in the wrong hands may well be on a train. Refusing
// until they are online would leave the device unlocking itself for the whole journey.
await UnlockedAsync();
await shell.RegisterDeviceCommand.ExecuteAsync(null);
await shell.LockCommand.ExecuteAsync(null);
// The same keystore, so this machine still holds its device key — only the network is gone.
var offline = new MainWindowViewModel(
paths,
caches,
workspace,
new VaultKnownHostStore(),
deviceKeys,
(_, _) => throw new InvalidOperationException("The shell went to the network."),
TimeProvider.System,
CheapProfile);
await using var _ = offline.ConfigureAwait(false);
await offline.StartAsync(Token);
offline.IsOnline.ShouldBeFalse();
offline.Passphrase = Passphrase;
await offline.UnlockCommand.ExecuteAsync(null);
offline.State.ShouldBe(ShellState.Unlocked, offline.StatusMessage);
offline.CanForgetDevice.ShouldBeTrue();
await offline.ForgetDeviceCommand.ExecuteAsync(null);
offline.StatusMessage.ShouldContain("still lists it");
offline.CanForgetDevice.ShouldBeFalse();
server.RegisteredDevices.Count.ShouldBe(1, "nothing reached the server, and it must not pretend");
// The half that decides whether this machine may let itself in happened anyway.
await offline.LockCommand.ExecuteAsync(null);
await offline.StartAsync(Token);
offline.CanUnlockWithDevice.ShouldBeFalse();
}
[Fact] [Fact]
public async Task OnAMachineWithNoKeystore_NeitherAffordanceAppears() public async Task OnAMachineWithNoKeystore_NeitherAffordanceAppears()
{ {
@@ -175,7 +175,44 @@ public sealed class DeviceUnlockTests : IAsyncLifetime
} }
[Fact] [Fact]
public async Task ForgettingTheDevice_SendsThisMachineBackToThePassphrase() 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 using (var first = await UnlockAsync())
{ {
@@ -184,16 +221,47 @@ public sealed class DeviceUnlockTests : IAsyncLifetime
await using (var second = (await Opener().UnlockWithDeviceAsync(deviceKeys, Token)).Session!) await using (var second = (await Opener().UnlockWithDeviceAsync(deviceKeys, Token)).Session!)
{ {
await second.ForgetDeviceAsync(deviceKeys, Token); var revocation = await second.ForgetDeviceAsync(api: null, deviceKeys, Token);
revocation.ShouldBe(DeviceRevocation.LocalOnly);
} }
var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token); (await Opener().UnlockWithDeviceAsync(deviceKeys, Token)).Status
.ShouldBe(UnlockStatus.NoDeviceKey);
outcome.Status.ShouldBe(UnlockStatus.NoDeviceKey); server.RegisteredDevices.Count.ShouldBe(1, "nothing reached the server, and it must not pretend");
}
// And the passphrase still works, which is the property that makes forgetting safe to offer. [Fact]
await using var byPassphrase = await UnlockAsync(); public async Task ForgettingADeviceThatWasNeverRegistered_SaysSoAndAsksTheServerNothing()
byPassphrase.ActiveVaultId.ShouldNotBe(Guid.Empty); {
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] [Fact]
@@ -50,6 +50,9 @@ internal sealed class FakeAccountServer : IAccountApi
/// </remarks> /// </remarks>
internal Dictionary<string, byte[]> RegisteredDevices { get; } = new(StringComparer.Ordinal); internal Dictionary<string, byte[]> RegisteredDevices { get; } = new(StringComparer.Ordinal);
/// <summary>The id issued for each registered public key, so revocation has something to name.</summary>
private readonly Dictionary<string, Guid> deviceIds = new(StringComparer.Ordinal);
/// <inheritdoc /> /// <inheritdoc />
public Task<MeResponse> GetMeAsync(CancellationToken cancellationToken) public Task<MeResponse> GetMeAsync(CancellationToken cancellationToken)
{ {
@@ -130,14 +133,37 @@ internal sealed class FakeAccountServer : IAccountApi
var key = Convert.ToHexString(request.PublicKey); var key = Convert.ToHexString(request.PublicKey);
if (!RegisteredDevices.TryAdd(key, request.WrappedPrivateKey))
{
RegisteredDevices[key] = request.WrappedPrivateKey; RegisteredDevices[key] = request.WrappedPrivateKey;
// The same id for the same public key, which the real service does and this fake used to claim in a
// comment while handing back a fresh Guid every call. That difference is invisible until something
// revokes by id, at which point a test would be revoking an id the server never issued.
if (!deviceIds.TryGetValue(key, out var deviceId))
{
deviceId = Guid.CreateVersion7();
deviceIds[key] = deviceId;
} }
return Task.FromResult(new RegisterDeviceResponse( return Task.FromResult(new RegisterDeviceResponse(deviceId, DateTimeOffset.UnixEpoch));
DeviceId: Guid.CreateVersion7(), }
EnrolledAt: DateTimeOffset.UnixEpoch));
/// <inheritdoc />
public Task<bool> RevokeDeviceAsync(Guid deviceId, CancellationToken cancellationToken)
{
var key = deviceIds.FirstOrDefault(entry => entry.Value == deviceId).Key;
if (key is null)
{
return Task.FromResult(false);
}
deviceIds.Remove(key);
// The wrap goes with it, as the foreign key's cascade does on the real server. A fake that kept the
// wrap would let a test claiming to prove revocation pass while the dangerous half survived.
RegisteredDevices.Remove(key);
return Task.FromResult(true);
} }
/// <summary>Drops the vault grant, as a rekey does until it is re-issued.</summary> /// <summary>Drops the vault grant, as a rekey does until it is re-issued.</summary>