Files
jaap-jan 49f617b450 Wire the Avalonia shell to the vault
The host list now comes from the vault instead of from a form. A fresh
machine takes a server URL, signs in through the browser, enrolls, and
from then on opens with the passphrase alone.

DodoSSH.Client.Session is the composition layer: where a profile lives,
how it unlocks, and how a machine gets one. ClientPaths picks a
non-roaming per-OS directory — %LOCALAPPDATA% and never %APPDATA%,
because a SQLite cache that roams between two machines is a corrupt one,
and each machine's outbox is its own. SessionOpener needs no transport at
all and could not reach one if it wanted to; that is the offline unlock,
asserted rather than asserted about. A wrong passphrase, a stale KDF and a
grant revoked by a rekey are three different answers, because the remedies
are three different things and telling someone to retype a passphrase that
was never the problem is worse than saying nothing.

The shell's states are the onboarding story. The recovery code gets its
own state that cannot be clicked past: it exists for one moment, losing it
with the passphrase loses the vault, and there is no server-side reset by
design. It is dropped from memory on confirmation rather than merely
hidden.

Sign-in is a delegate over IVaultServer, so the whole state machine runs
in a test against an in-memory server — no browser, no identity provider,
no toolkit. The view models are plain observable objects, which is what
makes that possible. What it does not cover is whether the XAML binds to
the right names; that needs a rendered tree and Avalonia.Headless, and is
its own piece of work.

Three things found by doing it rather than by reading it:

- Pooled SQLite connections keep the database file open after the last
  context is disposed. On Windows that means locked, so the application
  could never replace its own cache — and a test could not clean up after
  itself, which is how it surfaced. Dispose now clears the pool.
- EF's SQLite provider puts the database in WAL mode, so the cache is
  three files. A comment in ClientCacheFactory claimed the opposite;
  reading PRAGMA journal_mode off a real launch settled it. WAL is the
  right mode here — a sync pass writes while the interface reads — so the
  comment was wrong on the merits as well as on the fact.
- Enrolling a device key with nowhere to keep the private half would put a
  wrap on the server nobody can open and make the device list claim this
  machine can unlock without a passphrase. Device binding is now optional
  and the shell declines it until the OS keystore is wired.

Verified on Windows: the client created %LOCALAPPDATA%\DodoSSH\cache.db
and migrated it on first launch, and msedgewebview2 held an established
connection to the data plane while the unlock overlay covered it — which
is the point of covering the WebView rather than collapsing it, since a
NativeWebView that is never laid out is never realised.

630 tests, up from 593. The recovery-code gate and the offline unlock were
each verified by breaking them and watching the right test fail.

Still to do for M1's actual definition of done: the manual run against the
real API and a real Keycloak. Credentials are not a synced entity type
yet, so a connection still asks for a password, and the interface says so
rather than implying otherwise.
2026-07-29 11:02:19 +02:00

363 lines
13 KiB
C#

using System.Net;
using System.Text.Json;
using DodoSSH.Client.Auth;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Api.Tests;
/// <summary>
/// The client half of enrollment: what it sends, and what it keeps to itself.
/// </summary>
/// <remarks>
/// The most valuable assertions here are the negative ones about the request body. The server is
/// supposed to be unable to read anything it stores, and this is where that either holds or quietly
/// stops holding — a refactor that put a passphrase or a private key into the request would be
/// invisible to every other test in the repository.
/// </remarks>
public sealed class ClientEnrollmentTests : IDisposable
{
private const string Passphrase = "correct horse battery staple";
private const string EnrollmentPath = "/api/v1/me/enrollment";
private static readonly Guid UserId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f");
private readonly StubServer server = new();
private readonly HttpClient http = new();
public ClientEnrollmentTests() => http.BaseAddress = server.BaseUrl;
/// <inheritdoc />
public void Dispose()
{
http.Dispose();
server.Dispose();
}
[Fact]
public async Task Enroll_SendsAStatementTheServerCanVerify()
{
var binding = new CapturingKeyBinding();
var outcome = await EnrollAsync(binding);
using var bundle = outcome.Bundle;
var request = ReadRequest();
// The statement must describe the caller, or the server rejects it — and the signature must
// verify against the statement's own signing key, which is what proves possession.
request.Statement.Issuer.ShouldBe("https://idp.example/realms/dodossh");
request.Statement.Subject.ShouldBe("alice");
request.Statement.KeyGeneration.ShouldBe(1);
request.Statement.EncryptionPublicKey.ShouldBe(bundle.EncryptionPublicKey);
request.Statement.SigningPublicKey.ShouldBe(bundle.SigningPublicKey);
DshSignatures.VerifyKeyStatement(
request.Statement.SigningPublicKey,
KeyStatementCodec.Encode(ToFields(request.Statement)),
request.StatementSignature)
.ShouldBeTrue();
}
[Fact]
public async Task Enroll_BindsTheKeysWithTheStatementsOwnHash()
{
// The nonce handed to the identity provider must be this statement's hash and no other,
// otherwise the token binds keys nobody is publishing.
var binding = new CapturingKeyBinding();
var outcome = await EnrollAsync(binding);
outcome.Bundle.Dispose();
var request = ReadRequest();
binding.RequestedNonce.ShouldBe(
KeyStatementCodec.ComputeNonce(ToFields(request.Statement)));
}
[Fact]
public async Task Enroll_SendsNothingTheServerCouldUseToOpenTheVault()
{
var outcome = await EnrollAsync(new CapturingKeyBinding());
using var bundle = outcome.Bundle;
var body = server.LastBody(EnrollmentPath);
// The passphrase and the recovery code exist only on this machine.
body.ShouldNotContain(Passphrase);
body.ShouldNotContain(outcome.RecoveryCode);
// Nor may any private key appear, in any encoding the serialiser might have chosen.
body.ShouldNotContain(Convert.ToBase64String(outcome.PersonalVaultKey));
outcome.DevicePrivateKey.ShouldNotBeNull("this enrollment did bind a device");
body.ShouldNotContain(Convert.ToBase64String(outcome.DevicePrivateKey));
body.ShouldNotContain(Convert.ToHexString(outcome.PersonalVaultKey));
}
[Fact]
public async Task Enroll_WrapsTheSameBundleThreeWays()
{
// One bundle, several wraps, which is what makes a passphrase change a single-row update
// rather than a re-encryption of the vault.
var outcome = await EnrollAsync(new CapturingKeyBinding());
using var bundle = outcome.Bundle;
var request = ReadRequest();
var descriptor = DshAad.UserSecretBundle(UserId);
using var viaPassphrase = OpenWithPassphrase(request, Passphrase, descriptor);
viaPassphrase.ShouldNotBeNull();
viaPassphrase.SigningPublicKey.ShouldBe(bundle.SigningPublicKey);
request.RecoveryWrappedPrivateKey.ShouldNotBeNull();
request.RecoveryKdfParameters.ShouldNotBeNull();
using var viaRecovery = OpenWithRecovery(request, outcome.RecoveryCode, descriptor);
viaRecovery.ShouldNotBeNull();
viaRecovery.SigningPublicKey.ShouldBe(bundle.SigningPublicKey);
// The device wrap is sealed to the device key rather than derived, so it opens with the
// private half the caller was handed to put in the OS keystore.
request.DevicePublicKey.ShouldNotBeNull();
request.DeviceWrappedPrivateKey.ShouldNotBeNull();
using var deviceKey = NSec.Cryptography.Key.Import(
NSec.Cryptography.KeyAgreementAlgorithm.X25519,
outcome.DevicePrivateKey,
NSec.Cryptography.KeyBlobFormat.RawPrivateKey);
using var viaDevice = UserSecretBundle.TryOpenSealed(
deviceKey, request.DeviceWrappedPrivateKey, descriptor);
viaDevice.ShouldNotBeNull();
viaDevice.SigningPublicKey.ShouldBe(bundle.SigningPublicKey);
}
[Fact]
public async Task Enroll_SendsKdfParametersStrongEnoughForTheServerToAccept()
{
// The server enforces a floor. Sending anything below it fails enrollment against a real
// server while passing every stub, so the values are asserted here rather than discovered
// later.
var outcome = await EnrollAsync(new CapturingKeyBinding());
outcome.Bundle.Dispose();
var request = ReadRequest();
request.KdfParameters.Algorithm.ShouldBe("argon2id");
request.KdfParameters.MemoryKibibytes.ShouldBe(Argon2Profile.PassphraseDefault.MemoryKibibytes);
request.KdfParameters.Passes.ShouldBe(Argon2Profile.PassphraseDefault.Passes);
request.KdfParameters.Parallelism.ShouldBe(1);
request.KdfParameters.Salt.Length.ShouldBe(CryptoSpec.SaltSize);
// A distinct salt per wrap. Reusing one would let a single cracking effort cover both.
request.RecoveryKdfParameters!.Salt.ShouldNotBe(request.KdfParameters.Salt);
}
[Fact]
public async Task Enroll_SignsThePersonalVaultGrantOverTheCanonicalTuple()
{
var outcome = await EnrollAsync(new CapturingKeyBinding());
using var bundle = outcome.Bundle;
var request = ReadRequest();
var vault = request.PersonalVault;
var fingerprint = DshCrypto.ComputeFingerprint(
bundle.EncryptionPublicKey, bundle.SigningPublicKey);
var grant = GrantStatementCodec.Encode(
vault.VaultId,
keyGeneration: 1,
GrantPurpose.Member,
granteeUserId: UserId,
granteeKeyFingerprint: fingerprint,
wrappedKey: vault.WrappedVaultKey,
granterUserId: UserId,
granterKeyFingerprint: fingerprint,
keyLogHead: default,
grantedAt: vault.GrantedAt);
GrantStatementCodec.Verify(bundle.SigningPublicKey, grant, vault.GrantSignature)
.ShouldBeTrue("A grant the granter's own key cannot verify is one no client will accept.");
}
[Fact]
public async Task Enroll_SealsThePersonalVaultKeyToTheEnrollingIdentity()
{
var outcome = await EnrollAsync(new CapturingKeyBinding());
using var bundle = outcome.Bundle;
var vault = ReadRequest().PersonalVault;
VaultKeys.TryUnwrap(bundle.EncryptionKey, vault.WrappedVaultKey, vault.VaultId, 1)
.ShouldBe(outcome.PersonalVaultKey);
}
[Fact]
public async Task Enroll_ProducesAReadableRecoveryCode()
{
// Read aloud or copied off a screen, so the alphabet omits the characters that get
// mistranscribed.
var outcome = await EnrollAsync(new CapturingKeyBinding());
outcome.Bundle.Dispose();
outcome.RecoveryCode.ShouldNotBeNullOrWhiteSpace();
outcome.RecoveryCode.ShouldContain("-");
foreach (var character in outcome.RecoveryCode.Replace("-", string.Empty, StringComparison.Ordinal))
{
"0123456789ABCDEFGHJKMNPQRSTVWXYZ".ShouldContain(character);
}
}
[Fact]
public async Task Enroll_DisposesTheBundleWhenTheServerRefuses()
{
// The caller never receives the bundle on failure, so nothing else could release its guarded
// memory.
server.StubProblem(
EnrollmentPath, "POST", 409, ProblemCodes.AlreadyEnrolled, "Already enrolled.");
var enrollment = new ClientEnrollment(
new DodoSshApiClient(http, new StubTokenProvider()),
new CapturingKeyBinding(),
TimeProvider.System);
var exception = await Should.ThrowAsync<DodoSshApiException>(async () =>
await enrollment.EnrollAsync(
Me(), Passphrase, "laptop", "Personal", true, TestContext.Current.CancellationToken));
exception.Code.ShouldBe(ProblemCodes.AlreadyEnrolled);
exception.StatusCode.ShouldBe(HttpStatusCode.Conflict);
}
[Fact]
public async Task Enroll_RejectsAnEmptyPassphraseBeforeTouchingTheNetwork()
{
var binding = new CapturingKeyBinding();
var enrollment = new ClientEnrollment(
new DodoSshApiClient(http, new StubTokenProvider()),
binding,
TimeProvider.System);
await Should.ThrowAsync<ArgumentException>(async () =>
await enrollment.EnrollAsync(
Me(), string.Empty, "laptop", "Personal", true, TestContext.Current.CancellationToken));
binding.RequestedNonce.ShouldBeNull("Nothing should reach the identity provider.");
}
// ---- Helpers ----
private async Task<EnrollmentOutcome> EnrollAsync(CapturingKeyBinding binding)
{
server.StubEnrollment(new EnrollmentResponse(
UserId: UserId,
KeyGeneration: 1,
Fingerprint: new byte[32],
PersonalVaultId: Guid.CreateVersion7(),
DeviceId: Guid.CreateVersion7(),
KeyLogSequence: 1));
var enrollment = new ClientEnrollment(
new DodoSshApiClient(http, new StubTokenProvider()),
binding,
TimeProvider.System);
return await enrollment.EnrollAsync(
Me(),
Passphrase,
"laptop",
"Personal",
bindThisDevice: true,
TestContext.Current.CancellationToken);
}
private EnrollmentRequest ReadRequest()
{
var request = JsonSerializer.Deserialize(
server.LastBody(EnrollmentPath),
DodoSshJsonContext.Default.EnrollmentRequest);
request.ShouldNotBeNull();
return request;
}
private static MeResponse Me() =>
new(
UserId: UserId,
Issuer: "https://idp.example/realms/dodossh",
Subject: "alice",
Email: "alice@example.com",
DisplayName: "Alice",
EnrollmentRequired: true,
KeyGeneration: null,
WrappedPrivateKey: null,
KdfParameters: null,
Vaults: []);
private static UserSecretBundle? OpenWithPassphrase(
EnrollmentRequest request,
string passphrase,
AadDescriptor descriptor)
{
// Reconstructed from the stored parameters, exactly as a client unlocking on another device
// would do after reading them from /me.
using var master = MasterKey.Derive(
passphrase,
request.KdfParameters.Salt,
Argon2Profile.FromStoredParameters(
request.KdfParameters.MemoryKibibytes,
request.KdfParameters.Passes,
request.KdfParameters.Parallelism));
return master.TryOpenBundle(request.WrappedPrivateKey, descriptor);
}
private static UserSecretBundle? OpenWithRecovery(
EnrollmentRequest request,
string recoveryCode,
AadDescriptor descriptor)
{
var parameters = request.RecoveryKdfParameters!;
using var master = MasterKey.Derive(
recoveryCode,
parameters.Salt,
Argon2Profile.FromStoredParameters(
parameters.MemoryKibibytes, parameters.Passes, parameters.Parallelism));
return master.TryOpenBundle(request.RecoveryWrappedPrivateKey!, descriptor);
}
private static KeyStatementFields ToFields(KeyStatement statement) =>
new(
statement.Version,
statement.Issuer,
statement.Subject,
statement.Email,
statement.EncryptionPublicKey,
statement.SigningPublicKey,
statement.KeyGeneration,
statement.CreatedAt,
statement.DeviceName);
/// <summary>Records the nonce it was asked to bind, and returns a token carrying it.</summary>
private sealed class CapturingKeyBinding : IKeyBindingAuthorizer
{
internal string? RequestedNonce { get; private set; }
public Task<string> AuthorizeKeyBindingAsync(
string bindingNonce,
CancellationToken cancellationToken)
{
RequestedNonce = bindingNonce;
// Shape only. The real token's signature and nonce are the server's to verify, and doing
// it here would just be testing the stub.
return Task.FromResult($"header.{bindingNonce}.signature");
}
}
}