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
+6 -6
View File
@@ -136,7 +136,7 @@ internal sealed class ItemReconciler(
SyncEntityType.Host,
remote.EntityId,
ConflictKind.FieldOverridden,
ConflictDetailCodec.Encode(
ConflictDetails.Encode(
$"An item with this id already existed on the server at version {remote.Version}. "
+ "The version from this machine was kept; the server's values are recorded here."),
cancellationToken).ConfigureAwait(false);
@@ -195,7 +195,7 @@ internal sealed class ItemReconciler(
SyncEntityType.Host,
remote.EntityId,
ConflictKind.FieldOverridden,
ConflictDetailCodec.Encode(
ConflictDetails.Encode(
$"'{merged.Merged.Label}' was edited in two places at once. "
+ $"{merged.Conflicts.Count} field(s) could not be reconciled automatically.",
merged.Conflicts),
@@ -271,7 +271,7 @@ internal sealed class ItemReconciler(
SyncEntityType.Host,
remote.EntityId,
ConflictKind.RemoteDeleteResurrected,
ConflictDetailCodec.Encode(
ConflictDetails.Encode(
$"'{local.Host.Label}' was deleted elsewhere while this machine had unsaved changes. "
+ $"The deletion stands and the local version was kept as '{restored.Label}'."),
cancellationToken).ConfigureAwait(false);
@@ -294,7 +294,7 @@ internal sealed class ItemReconciler(
SyncEntityType.Host,
remote.EntityId,
ConflictKind.LocalDeleteOverridden,
ConflictDetailCodec.Encode(
ConflictDetails.Encode(
"This host was edited elsewhere after it was deleted here, so the deletion was not "
+ "applied. Delete it again if that is still what you want."),
cancellationToken).ConfigureAwait(false);
@@ -372,7 +372,7 @@ internal sealed class ItemReconciler(
SyncEntityType.Host,
remote.EntityId,
ConflictKind.TooNewToEdit,
ConflictDetailCodec.Encode(
ConflictDetails.Encode(
"This host was written by a newer version of DodoSSH. It can be read but not "
+ "merged here, because saving it would discard fields this version does not know "
+ "about."),
@@ -402,7 +402,7 @@ internal sealed class ItemReconciler(
SyncEntityType.Host,
entityId,
ConflictKind.Undecryptable,
ConflictDetailCodec.Encode(
ConflictDetails.Encode(
"This host could not be decrypted, so the change made here could not be merged. "
+ "The vault key may have been rotated, or the stored payload may not belong to this "
+ "item."),
+1 -1
View File
@@ -479,7 +479,7 @@ public sealed class SyncEngine
operation.EntityType,
operation.EntityId,
ConflictKind.Rejected,
ConflictDetailCodec.Encode(reason),
ConflictDetails.Encode(reason),
cancellationToken).ConfigureAwait(false);
report.Parked++;
+11 -3
View File
@@ -154,14 +154,18 @@ public sealed record ConflictDetailEntry(
public sealed record ConflictDetail(string Summary, IReadOnlyList<ConflictDetailEntry> Fields);
/// <summary>
/// Serialises what a merge discarded, for the conflict log.
/// Reads and writes what a merge discarded, for the conflict log.
/// </summary>
/// <remarks>
/// The bytes crossing into <c>ConflictStore</c> are plaintext vault content and are sealed there under
/// the LocalCacheKey. Deliberately its own format rather than the item payload's: this is local
/// bookkeeping and is never pushed, so it has no compatibility obligation to any other client.
/// <para>
/// Reading is public because the whole justification for resolving a conflict automatically is that the
/// overridden value gets shown. A codec only the writer could use would make that impossible.
/// </para>
/// </remarks>
internal static class ConflictDetailCodec
public static class ConflictDetails
{
internal static byte[] Encode(string summary, IReadOnlyList<HostFieldConflict> conflicts) =>
JsonSerializer.SerializeToUtf8Bytes(
@@ -178,7 +182,11 @@ internal static class ConflictDetailCodec
internal static byte[] Encode(string summary) => Encode(summary, []);
/// <summary>Reads a detail back, for display.</summary>
internal static ConflictDetail? TryDecode(ReadOnlySpan<byte> utf8)
/// <returns>
/// The detail, or <see langword="null"/> when the record will not parse — which is what an entry
/// written before a passphrase change looks like, since its sealed bytes no longer open.
/// </returns>
public static ConflictDetail? TryRead(ReadOnlySpan<byte> utf8)
{
try
{