Files
DodoSSH/tests/DodoSSH.Client.App.Tests/FakeVaultServer.cs
T
jaap-jan db4a8ed3d3 Let an already-enrolled account register a device key
The first of the three pieces ADR 0007 needs, and the one that was a discovery
rather than a plan. EnrollmentService.AddDevice runs only during enrollment, so
without an endpoint the device-unlock feature would have reached accounts created
after it shipped and no others — which is to say none of the ones that exist. The
code even said so: "the devices endpoint sets it properly when it lands."

POST /api/v1/me/devices takes a name, an X25519 public key and the bundle sealed
to it, and writes a device row plus a UserKeyWrapKind.Device wrap.

Possession is proved by construction, so there is no challenge. The wrap is the
secret bundle sealed to the supplied public key, and only something that has
opened that bundle can produce it. A caller who seals the wrong bytes registers a
device that cannot unlock, which harms nobody else; the server cannot tell the
difference and must not pretend to, because it holds no key that opens either.
That is also why the client must be unlocked to call this at all.

It is the one endpoint in the /me group that requires enrollment, and it says so
itself rather than relying on the group. The group deliberately does not: GET /
and POST /enrollment are how a client discovers it needs to enroll and then does
so, and gating those on enrollment would make enrollment unreachable. Adding the
stricter policy to this route alone means an unenrolled caller is told
"enrollment-required" by the authorization handler rather than getting a 400 about
the shape of a request that was fine.

Idempotent on the public key, and 200 rather than 201 for the reason enrollment
gives: a retry of an identical request returns the same body, so there is no
single moment of creation to point a Location header at. A second row for one key
would mean a device list with a duplicate in it and two wraps to revoke instead
of one. Mutation tested — removing the lookup fails
RegisterDevice_TwiceWithTheSameKey_ReturnsTheSameDeviceAndAddsNoSecondWrap and
nothing else.

That test also found a real defect, in the way these usually surface: two
timestamps that print identically and are not equal. TimeProvider reports
100-nanosecond ticks and PostgreSQL's timestamp with time zone keeps microseconds,
so the first call returned a value that no later read of the row would ever
produce, and the idempotent retry answered with a different timestamp for the same
device. Nothing breaks, which is what makes it worth fixing: the service now
truncates to the precision the column actually holds, so the response is the same
value every time it is asked for. The repo already had a precedent for this class
of thing in KeyLogChain.TruncateTimestamp; it just had not been applied here.

The platform is deliberately not carried on the wire, which leaves
Device.Platform unreported and the stale comment corrected rather than fulfilled.
It would be a display-only field, and a Contracts enum mirroring the domain's
DevicePlatform is exactly the shape of duplication that has produced three
self-consistent bugs in this repository. A device list that wants it can add a
mapping table and a test pinning the two together, which is what the sync entity
types already do.

Its own problem code and exception rather than reusing enrollment's, whose rules
it largely shares. Registering a device is not enrolling, and a client showing
"your enrollment was rejected" because somebody set up a fingerprint reader would
be describing the wrong thing. The validation shares the limit constants —
MaximumWrapBytes, MaximumDeviceNameLength, PublicKeySize — and not the four-line
guards, which would have had to be parameterised over which exception to throw for
less than they cost.

Both in-memory fakes implement it properly rather than throwing: they record the
wrap so a test can assert it arrived, and refuse before enrollment as the real
endpoint's policy does. A fake that answered where the server refuses is a fake
that can make a real bug pass.

866 tests green, 8 of them new. Zero warnings, dotnet format clean.

Still to come: the protector seam with the wrap cached locally so device unlock
works offline, then the Windows Hello implementation and the unlock-screen UI —
which is where the Windows target framework lands and where automated testing
stops.
2026-07-30 13:18:09 +02:00

249 lines
8.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>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.");
}
RegisteredDevices[Convert.ToHexString(request.PublicKey)] = request.WrappedPrivateKey;
return Task.FromResult(new RegisterDeviceResponse(
DeviceId: Guid.CreateVersion7(),
EnrolledAt: DateTimeOffset.UnixEpoch));
}
// ---- 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);
}
}