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.
This commit is contained in:
2026-07-29 11:02:19 +02:00
parent 8d2416a602
commit 49f617b450
33 changed files with 5405 additions and 210 deletions
+189
View File
@@ -0,0 +1,189 @@
using DodoSSH.Client.Api;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Sync;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Session;
/// <summary>A conflict, decoded and ready to show.</summary>
/// <param name="Id">The conflict record, so it can be acknowledged.</param>
/// <param name="EntityId">The item it happened to.</param>
/// <param name="Kind">What happened.</param>
/// <param name="Summary">One line for a person.</param>
/// <param name="Fields">
/// Everything the merge overrode, with the discarded values. Empty for the kinds that have no field
/// detail — a rejected push, or an item that would not decrypt.
/// </param>
/// <param name="DetectedAt">When it was noticed.</param>
public sealed record ConflictNotice(
Guid Id,
Guid EntityId,
ConflictKind Kind,
string Summary,
IReadOnlyList<ConflictDetailEntry> Fields,
DateTimeOffset DetectedAt);
/// <summary>
/// An unlocked vault: the keys are in memory, the cache is open, and the hosts are readable.
/// </summary>
/// <remarks>
/// <para>
/// Everything a session owns dies with it — the identity bundle, the vault keys and the cache key. That
/// is the whole reason this is a disposable object rather than a set of long-lived services: locking is
/// disposing, and there is exactly one place that has to be right.
/// </para>
/// <para>
/// The sync engine is <em>not</em> held here. It carries no state, so it is constructed per pass around
/// whichever transport the caller currently has — which models the actual situation, where a session is
/// perfectly usable with no network at all and syncing is the occasional thing that needs one.
/// </para>
/// </remarks>
public sealed class VaultSession : IAsyncDisposable
{
private readonly UserSecretBundle bundle;
private readonly LocalCacheProtector protector;
private readonly VaultKeyring keyring;
private readonly TimeProvider clock;
private readonly SyncOptions options;
private bool disposed;
internal VaultSession(
StoredUnlockMaterial profile,
IReadOnlyList<StoredVault> vaults,
Guid activeVaultId,
UserSecretBundle bundle,
LocalCacheProtector protector,
VaultKeyring keyring,
ClientCacheFactory caches,
TimeProvider clock,
SyncOptions options)
{
Profile = profile;
Vaults = vaults;
ActiveVaultId = activeVaultId;
this.bundle = bundle;
this.protector = protector;
this.keyring = keyring;
this.clock = clock;
this.options = options;
Items = new ItemStore(caches, protector);
Outbox = new OutboxStore(caches, protector, clock);
SyncState = new SyncStateStore(caches);
Conflicts = new ConflictStore(caches, protector, clock);
Vault = new VaultStore(caches, clock);
Hosts = new HostRepository(Items, Outbox, keyring);
}
/// <summary>Who this session belongs to, and the material that unlocked it.</summary>
public StoredUnlockMaterial Profile { get; }
/// <summary>Every vault this user can reach, readable or not.</summary>
public IReadOnlyList<StoredVault> Vaults { get; }
/// <summary>The vault the interface is showing. The personal one, for now.</summary>
public Guid ActiveVaultId { get; }
/// <summary>Hosts, decrypted, with unpushed local changes laid over them.</summary>
public HostRepository Hosts { get; }
/// <summary>Vaults whose grant could not be opened, so their items cannot be read.</summary>
public IReadOnlyList<Guid> UnreadableVaults => keyring.Unopened;
internal ItemStore Items { get; }
internal OutboxStore Outbox { get; }
internal SyncStateStore SyncState { get; }
internal ConflictStore Conflicts { get; }
internal VaultStore Vault { get; }
/// <summary>Runs one synchronisation pass over the active vault.</summary>
/// <param name="api">The transport. Supplied per call because a session outlives any one connection.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<SyncReport> SyncAsync(ISyncApi api, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(api);
var engine = new SyncEngine(
api, Items, Outbox, SyncState, Conflicts, keyring, clock, options);
return engine.SyncAsync(ActiveVaultId, cancellationToken);
}
/// <summary>
/// Reads the conflicts a person still needs to see.
/// </summary>
/// <remarks>
/// A conflict whose detail will not decode is still reported, with the reason in place of the
/// summary. The record itself — which item, when, what kind — remains useful even when the
/// discarded value has become unreadable, and dropping the row would be the one outcome the whole
/// conflict log exists to avoid.
/// </remarks>
public async Task<IReadOnlyList<ConflictNotice>> ReadConflictsAsync(
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
var stored = await Conflicts
.ListAsync(ActiveVaultId, includeAcknowledged: false, cancellationToken)
.ConfigureAwait(false);
return [.. stored.Select(Describe)];
}
/// <summary>Marks a conflict as seen, keeping the discarded value retrievable.</summary>
public Task<bool> AcknowledgeConflictAsync(Guid conflictId, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
return Conflicts.AcknowledgeAsync(conflictId, cancellationToken);
}
/// <summary>How many local changes are waiting to be pushed.</summary>
public async Task<int> PendingChangeCountAsync(CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
var pending = await Outbox.ListAllAsync(ActiveVaultId, cancellationToken).ConfigureAwait(false);
return pending.Count;
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
if (disposed)
{
return ValueTask.CompletedTask;
}
disposed = true;
// Order is not important — none of these depend on another — but completeness is. Missing one
// leaves key material in memory for the life of the process, which is the opposite of what
// locking is supposed to mean.
keyring.Dispose();
protector.Dispose();
bundle.Dispose();
return ValueTask.CompletedTask;
}
private static ConflictNotice Describe(StoredConflict conflict)
{
var detail = ConflictDetails.TryRead(conflict.Detail);
return new ConflictNotice(
conflict.Id,
conflict.EntityId,
conflict.Kind,
detail?.Summary ?? "The details of this conflict could not be read.",
detail?.Fields ?? [],
conflict.DetectedAt);
}
}