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.
278 lines
9.5 KiB
C#
278 lines
9.5 KiB
C#
using DodoSSH.Client.Api;
|
|
using DodoSSH.Client.Auth;
|
|
using DodoSSH.Client.Session;
|
|
using DodoSSH.Client.Sync;
|
|
using DodoSSH.Contracts;
|
|
|
|
namespace DodoSSH.Client.App.Tests;
|
|
|
|
/// <summary>
|
|
/// A signed-in server, without the signing in.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Stands in for a <c>ServerConnection</c> so the shell's state machine can be driven end to end. The
|
|
/// account half stores what it is given and reports it back, because the provisioner re-reads <c>/me</c>
|
|
/// after enrolling and a stub that echoed the request would make that check meaningless. The sync half
|
|
/// applies pushes and serves them back as a change log, which is enough for the shell — the interesting
|
|
/// conflict behaviour is covered in <c>DodoSSH.Client.Sync.Tests</c> against a server that enforces
|
|
/// version checks.
|
|
/// </remarks>
|
|
internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKeyBindingAuthorizer
|
|
{
|
|
private readonly List<SyncChange> log = [];
|
|
|
|
/// <remarks>
|
|
/// Keyed on the entity type as well as the id, as the server's tables and the client's cache both are.
|
|
/// Ids are UUIDv7 so a collision between two types will not happen by accident — but a fake that would
|
|
/// treat a host and a key with one id as one row is a fake that could make a real bug pass.
|
|
/// </remarks>
|
|
private readonly Dictionary<(SyncEntityType Type, Guid EntityId), SyncChange> rows = [];
|
|
|
|
private KeyStatement? statement;
|
|
private byte[]? wrappedPrivateKey;
|
|
private KdfParameters? kdfParameters;
|
|
private VaultSummary? personalVault;
|
|
|
|
internal Guid UserId { get; } = Guid.Parse("0192f0c8-4444-7aaa-8bbb-dddddddddddd");
|
|
|
|
internal int EnrollmentCount { get; private set; }
|
|
|
|
internal int PushCount { get; private set; }
|
|
|
|
internal bool IsEnrolled => statement is not null;
|
|
|
|
internal int LiveRowCount => rows.Values.Count(row => row.Operation != SyncOperation.Delete);
|
|
|
|
/// <summary>Device wraps registered after enrollment, keyed on the device public key.</summary>
|
|
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>
|
|
internal Exception? SignInFailure { get; set; }
|
|
|
|
/// <summary>
|
|
/// When set, every synchronisation throws.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A server that answers but fails, as distinct from no server at all. The two are handled quite
|
|
/// differently by a background pass: one is expected and silent, the other has to not overwrite
|
|
/// whatever the user was reading.
|
|
/// </remarks>
|
|
internal Exception? SyncFailure { get; set; }
|
|
|
|
/// <inheritdoc />
|
|
public Uri ServerUrl { get; } = new("https://dodossh.example");
|
|
|
|
/// <inheritdoc />
|
|
public IAccountApi Account => this;
|
|
|
|
/// <inheritdoc />
|
|
public ISyncApi Sync => this;
|
|
|
|
/// <inheritdoc />
|
|
public IKeyBindingAuthorizer KeyBinding => this;
|
|
|
|
/// <inheritdoc />
|
|
public SyncOptions SyncOptions => SyncOptions.Default;
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
// Nothing to release; the shell disposes this on lock and on shutdown, and both paths have to be
|
|
// safe to run more than once.
|
|
}
|
|
|
|
// ---- Identity provider ----
|
|
|
|
/// <inheritdoc />
|
|
public Task<string> AuthorizeKeyBindingAsync(string bindingNonce, CancellationToken cancellationToken) =>
|
|
Task.FromResult("stub-id-token");
|
|
|
|
// ---- Account ----
|
|
|
|
/// <inheritdoc />
|
|
public Task<MeResponse> GetMeAsync(CancellationToken cancellationToken) =>
|
|
Task.FromResult(new MeResponse(
|
|
UserId,
|
|
"https://idp.example/realms/dodossh",
|
|
"alice",
|
|
"alice@example.com",
|
|
"Alice Example",
|
|
EnrollmentRequired: !IsEnrolled,
|
|
KeyGeneration: statement?.KeyGeneration,
|
|
WrappedPrivateKey: wrappedPrivateKey,
|
|
KdfParameters: kdfParameters,
|
|
Vaults: personalVault is null ? [] : [personalVault]));
|
|
|
|
/// <inheritdoc />
|
|
public Task<EnrollmentResponse> EnrollAsync(
|
|
EnrollmentRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
EnrollmentCount++;
|
|
|
|
statement = request.Statement;
|
|
wrappedPrivateKey = request.WrappedPrivateKey;
|
|
kdfParameters = request.KdfParameters;
|
|
|
|
personalVault = new VaultSummary(
|
|
request.PersonalVault.VaultId,
|
|
request.PersonalVault.Name,
|
|
IsPersonal: true,
|
|
TeamId: null,
|
|
KeyGeneration: 1,
|
|
Permissions: 31,
|
|
request.PersonalVault.WrappedVaultKey,
|
|
RekeyRequired: false);
|
|
|
|
return Task.FromResult(new EnrollmentResponse(
|
|
UserId,
|
|
KeyGeneration: 1,
|
|
Fingerprint: new byte[32],
|
|
request.PersonalVault.VaultId,
|
|
DeviceId: null,
|
|
KeyLogSequence: 1));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// Records the wrap so a test can assert it reached the server, and refuses before enrollment as the
|
|
/// real endpoint's <c>Auth.EnrolledPolicy</c> does.
|
|
/// </remarks>
|
|
public Task<RegisterDeviceResponse> RegisterDeviceAsync(
|
|
RegisterDeviceRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!IsEnrolled)
|
|
{
|
|
throw new DodoSshApiException(
|
|
System.Net.HttpStatusCode.Forbidden,
|
|
ProblemCodes.EnrollmentRequired,
|
|
"This account has no identity key yet.");
|
|
}
|
|
|
|
var key = Convert.ToHexString(request.PublicKey);
|
|
|
|
RegisteredDevices[key] = request.WrappedPrivateKey;
|
|
|
|
// 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 ----
|
|
|
|
/// <inheritdoc />
|
|
public Task<SyncPullResponse> SyncPullAsync(
|
|
Guid vaultId,
|
|
SyncPullRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (SyncFailure is { } failure)
|
|
{
|
|
return Task.FromException<SyncPullResponse>(failure);
|
|
}
|
|
|
|
var after = request.Cursor is null
|
|
? 0
|
|
: long.Parse(request.Cursor.AsSpan("app-v1:".Length), provider: null);
|
|
|
|
var page = log.Where(change => change.ChangeSequence > after).ToList();
|
|
var next = page.Count > 0 ? page[^1].ChangeSequence : after;
|
|
|
|
return Task.FromResult(new SyncPullResponse(
|
|
page,
|
|
$"app-v1:{next}",
|
|
HasMore: false,
|
|
ServerTime: DateTimeOffset.FromUnixTimeSeconds(1_750_000_000),
|
|
CurrentKeyGeneration: 1));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<SyncPushResponse> SyncPushAsync(
|
|
Guid vaultId,
|
|
SyncPushRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
PushCount++;
|
|
|
|
var results = new List<SyncPushResult>(request.Operations.Count);
|
|
|
|
foreach (var operation in request.Operations)
|
|
{
|
|
results.Add(Apply(operation));
|
|
}
|
|
|
|
return Task.FromResult(new SyncPushResponse(results, $"app-v1:{log.Count}"));
|
|
}
|
|
|
|
private SyncPushResult Apply(SyncPushOperation operation)
|
|
{
|
|
rows.TryGetValue((operation.EntityType, operation.EntityId), out var existing);
|
|
|
|
var current = existing?.Operation == SyncOperation.Delete ? null : existing;
|
|
|
|
if (operation.ExpectedVersion != current?.Version)
|
|
{
|
|
return new SyncPushResult(
|
|
operation.OperationId,
|
|
SyncOperationStatus.Conflict,
|
|
current?.Version,
|
|
current?.ChangeSequence,
|
|
current,
|
|
null);
|
|
}
|
|
|
|
var sequence = log.Count + 1;
|
|
|
|
var change = new SyncChange(
|
|
operation.EntityType,
|
|
operation.EntityId,
|
|
operation.Operation,
|
|
Version: (current?.Version ?? 0) + 1,
|
|
ChangeSequence: sequence,
|
|
Payload: operation.Operation == SyncOperation.Delete ? null : operation.Payload,
|
|
PlaintextFields: operation.Operation == SyncOperation.Delete
|
|
? null
|
|
: operation.PlaintextFields,
|
|
UpdatedAt: DateTimeOffset.FromUnixTimeSeconds(1_750_000_000 + sequence));
|
|
|
|
rows[(operation.EntityType, operation.EntityId)] = change;
|
|
log.Add(change);
|
|
|
|
return new SyncPushResult(
|
|
operation.OperationId,
|
|
SyncOperationStatus.Applied,
|
|
change.Version,
|
|
sequence,
|
|
null,
|
|
null);
|
|
}
|
|
}
|