Add the server client and client-side enrollment

A typed client over DodoSSH.Contracts, and the orchestration that turns a
passphrase into an enrolled identity: generate keys, have the identity
provider sign over them, wrap the bundle three ways, create the personal
vault, publish.

Ordering here is forced, not chosen. The secret bundle's AAD binds to the
server-assigned user id, so /me has to be read before anything can be
wrapped -- which is exactly why /me provisions the account and returns its id
even while reporting that enrollment is required. That constraint was
designed into the server earlier; this is the first code that depends on it.

The grant tuple now has a real canonical encoding (crypto.md 7.3) rather
than the placeholder signature I would otherwise have had to invent and then
keep. §7 named the tuple without specifying how to encode it; this fills that
in with the same conventions as 7.1, and the self-grant at enrollment is
already in its final format. The signature covers SHA-256(wrappedKey) rather
than the key, so a verifier can check attribution without holding the vault
key at all.

The most valuable tests are the negative ones about the request body: the
server is meant to be unable to read what it stores, and a refactor that put
a passphrase or a private key into the enrollment request would be invisible
to every other test in the repository. So one asserts the body contains
neither the passphrase, the recovery code, nor any private key in base64 or
hex. Another opens the same bundle three ways -- passphrase, recovery code and
device key -- which is what makes a passphrase change a one-row update.

ClientEnrollment depends on IKeyBindingAuthorizer rather than the whole
OidcClient. It needs exactly one capability, and depending on the full client
would drag discovery and token exchange into every test of key binding.

Two things fixed while building it. The recovery code buffer was sized one
separator short, so every enrollment threw IndexOutOfRange -- caught
immediately because nine of ten tests failed identically. And the crypto
enum collided with Domain.GrantKind in the server, so it is GrantPurpose
there; the numeric values still have to match, which the doc and a test both
say.

448 tests pass, zero warnings on a clean rebuild, format clean.
This commit is contained in:
2026-07-28 22:42:56 +02:00
parent 5fccd53824
commit a878c2b6bb
15 changed files with 3301 additions and 1 deletions
@@ -0,0 +1,125 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using DodoSSH.Contracts;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
namespace DodoSSH.Client.Api.Tests;
/// <summary>A stand-in DodoSSH server.</summary>
/// <remarks>
/// Responses are built from the real contract types and the real source-generated serialiser, so the
/// client is reading exactly the shape a live server produces rather than hand-written JSON that
/// happens to satisfy it.
/// </remarks>
internal sealed class StubServer : IDisposable
{
private readonly WireMockServer server = WireMockServer.Start();
internal Uri BaseUrl => new(server.Url!, UriKind.Absolute);
/// <summary>Requests received, so tests can assert on what was sent.</summary>
internal IReadOnlyList<WireMock.Logging.ILogEntry> Requests => server.LogEntries.ToList();
internal void StubMe(MeResponse response) =>
StubJson("/api/v1/me", "GET", 200, JsonSerializer.Serialize(
response, DodoSshJsonContext.Default.MeResponse));
internal void StubEnrollment(EnrollmentResponse response) =>
StubJson("/api/v1/me/enrollment", "POST", 200, JsonSerializer.Serialize(
response, DodoSshJsonContext.Default.EnrollmentResponse));
internal void StubMeta(MetaResponse response) =>
StubJson("/api/v1/meta", "GET", 200, JsonSerializer.Serialize(
response, DodoSshJsonContext.Default.MetaResponse));
internal void StubPush(Guid vaultId, SyncPushResponse response) =>
StubJson($"/api/v1/vaults/{vaultId}/sync/push", "POST", 200, JsonSerializer.Serialize(
response, DodoSshJsonContext.Default.SyncPushResponse));
internal void StubPull(Guid vaultId, SyncPullResponse response) =>
StubJson($"/api/v1/vaults/{vaultId}/sync/pull", "POST", 200, JsonSerializer.Serialize(
response, DodoSshJsonContext.Default.SyncPullResponse));
/// <summary>Stubs an RFC 9457 problem response.</summary>
internal void StubProblem(string path, string method, int statusCode, string code, string detail)
{
var problem = new JsonObject
{
["type"] = ProblemCodes.TypeBaseUri + code,
["title"] = "Request failed",
["status"] = statusCode,
["detail"] = detail,
["code"] = code,
};
StubJson(path, method, statusCode, problem.ToJsonString());
}
/// <summary>Stubs a non-JSON error, as a reverse proxy in front of a dead server would return.</summary>
internal void StubGatewayError(string path, string method) =>
server
.Given(Request.Create().WithPath(path).UsingMethod(method))
.RespondWith(Response.Create()
.WithStatusCode(502)
.WithHeader("Content-Type", "text/html")
.WithBody("<html><body><h1>502 Bad Gateway</h1></body></html>"));
/// <summary>The body of the last request to a path.</summary>
internal string LastBody(string path)
{
var entries = server.LogEntries
.Where(e => e.RequestMessage?.Path?.EndsWith(path, StringComparison.Ordinal) == true)
.ToList();
if (entries.Count == 0)
{
throw new InvalidOperationException($"Nothing was sent to {path}.");
}
return entries[^1].RequestMessage?.Body ?? string.Empty;
}
/// <summary>The Authorization header of the last request to a path.</summary>
internal string? LastAuthorization(string path)
{
var entries = server.LogEntries
.Where(e => e.RequestMessage?.Path?.EndsWith(path, StringComparison.Ordinal) == true)
.ToList();
if (entries.Count == 0)
{
return null;
}
var headers = entries[^1].RequestMessage?.Headers;
return headers is not null && headers.TryGetValue("Authorization", out var values)
? values.FirstOrDefault()
: null;
}
/// <inheritdoc />
public void Dispose()
{
server.Stop();
server.Dispose();
}
private void StubJson(string path, string method, int statusCode, string body) =>
server
.Given(Request.Create().WithPath(path).UsingMethod(method))
.RespondWith(Response.Create()
.WithStatusCode(statusCode)
.WithHeader("Content-Type", "application/json")
.WithBody(body));
}
/// <summary>Hands out a fixed token, so tests can assert it reached the wire.</summary>
internal sealed class StubTokenProvider(string token = "test-access-token") : IAccessTokenProvider
{
/// <inheritdoc />
public ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken) =>
ValueTask.FromResult(token);
}