Public Access
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.
112 lines
5.1 KiB
C#
112 lines
5.1 KiB
C#
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>
|
|
/// 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;
|
|
}
|