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
+41 -11
View File
@@ -4,7 +4,10 @@ using Avalonia.Markup.Xaml;
using DodoSSH.Client.App.Terminal;
using DodoSSH.Client.App.ViewModels;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.App;
@@ -34,16 +37,24 @@ internal sealed partial class DodoSshApp : Application
}
/// <remarks>
/// Composed by hand rather than through a container. The graph is four objects deep, and an
/// indirection to read through would buy nothing at this size.
/// <para>
/// The workspace is a local captured by the closures below rather than a field, so this type does
/// not own a disposable it has no good place to dispose — an Avalonia <c>Application</c> has no
/// disposal hook of its own.
/// Composed by hand rather than through a container. The graph is a handful of objects deep and an
/// indirection to read through would buy nothing at this size.
/// </para>
/// <para>
/// Everything disposable is a local captured by the closures below rather than a field, because an
/// Avalonia <c>Application</c> has no disposal hook of its own and a type that owned them would have
/// nowhere honest to release them.
/// </para>
/// </remarks>
private static void Compose(IClassicDesktopStyleApplicationLifetime desktop)
{
var paths = ClientPaths.Default;
var caches = ClientCacheFactory.ForFile(paths.CacheFile);
// Known hosts are still in memory. The plan puts them in the vault as a synced entity so trust
// follows the user to every device, and SyncEntityType.KnownHostKey is reserved for it — but that
// entity type is not synced yet, so trust currently lasts one session.
var knownHosts = new InMemoryKnownHostStore();
var workspace = new TerminalWorkspace(
@@ -53,16 +64,30 @@ internal sealed partial class DodoSshApp : Application
workspace.Start();
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(workspace, knownHosts),
};
var browser = new SystemBrowserLauncher();
var viewModel = new MainWindowViewModel(
paths,
caches,
workspace,
knownHosts,
async (url, cancellationToken) => await ServerConnection
.SignInAsync(url, browser, TimeProvider.System, cancellationToken)
.ConfigureAwait(false),
TimeProvider.System);
desktop.MainWindow = new MainWindow { DataContext = viewModel };
// Started rather than awaited: the framework's initialisation must not block on a schema
// migration. The view model shows its own progress and handles its own failures, which is why
// discarding the task here is safe rather than merely convenient.
_ = viewModel.StartAsync(CancellationToken.None);
var shuttingDown = false;
// Shutdown is deferred rather than blocked on. Sessions hold SSH connections and a listening
// socket, and blocking the UI thread on their disposal is how an application comes to take
// several seconds to close — or deadlocks, if any of that disposal needs the UI thread.
// socket, and blocking the UI thread on their disposal is how an application comes to take several
// seconds to close — or deadlocks, if any of that disposal needs the UI thread.
desktop.ShutdownRequested += async (_, e) =>
{
if (shuttingDown)
@@ -73,8 +98,13 @@ internal sealed partial class DodoSshApp : Application
shuttingDown = true;
e.Cancel = true;
// The view model first: it holds the vault session, and disposing that is what zeroes the
// identity keys, the vault keys and the cache key.
await viewModel.DisposeAsync().ConfigureAwait(true);
await workspace.DisposeAsync().ConfigureAwait(true);
caches.Dispose();
desktop.Shutdown();
};
}
@@ -25,10 +25,19 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" />
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
<ProjectReference Include="../DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
</ItemGroup>
<ItemGroup>
<!--
The view models are plain CommunityToolkit.Mvvm objects and need no Avalonia to run, so the shell's
state machine is testable as ordinary code. That is the whole reason the sign-in step is a delegate.
-->
<InternalsVisibleTo Include="DodoSSH.Client.App.Tests" />
</ItemGroup>
<ItemGroup>
<!--
The renderer's files, including the vendored xterm bundles. Embedded rather than copied to
@@ -1,146 +1,423 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.App.Terminal;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Crypto;
namespace DodoSSH.Client.App.ViewModels;
/// <summary>Which of the shell's mutually exclusive screens is showing.</summary>
internal enum ShellState
{
/// <summary>Reading the cache to find out whether this machine is enrolled.</summary>
Starting = 0,
/// <summary>Nothing is cached. The user has to name a server and sign in, which needs a network.</summary>
NeedsServer = 1,
/// <summary>Signed in, but the account has no vault key yet.</summary>
NeedsEnrollment = 2,
/// <summary>
/// Showing the recovery code, and refusing to move on until the user confirms they have it.
/// </summary>
/// <remarks>
/// A separate state rather than a dismissible banner, because this is the only moment the code exists.
/// Losing it along with the passphrase means the vault is unrecoverable and there is no server-side
/// reset by design — so this is the one screen a user must not be able to click past.
/// </remarks>
ShowingRecoveryCode = 3,
/// <summary>Enrolled. The passphrase opens the vault, with or without a network.</summary>
Locked = 4,
/// <summary>Open.</summary>
Unlocked = 5,
}
/// <summary>
/// The shell: connect to a host, and surface host key trust decisions.
/// The shell: get to an unlocked vault, then hand over to <see cref="VaultViewModel"/>.
/// </summary>
/// <remarks>
/// <para>
/// Hosts are typed in directly for now. Reading them from the encrypted vault needs the local cache
/// and the sync client, which are the next pieces; this exists to prove the terminal path end to end
/// and is deliberately obvious about being temporary rather than looking like a finished feature.
/// The order of these states is the product's onboarding story. A fresh machine needs a server URL and one
/// browser sign-in; everything about the identity provider comes from
/// <c>/.well-known/dodossh-configuration</c>, so the user never configures an authority or a client id.
/// After that the network is optional — the cached salt and wrapped bundle mean the passphrase alone
/// unlocks, which is the state the application spends nearly all of its life in.
/// </para>
/// <para>
/// The two host key states are modelled separately and behave differently, which is the point. An
/// unknown host offers a Trust button. A changed key offers nothing — see
/// <see cref="SshHostKeyMismatchException"/> for why there is no "continue anyway" here.
/// Key derivation runs on a worker thread. At the shipped profile it is a third of a second of solid CPU,
/// and doing that on the UI thread would freeze the window at exactly the moment the user is watching it.
/// </para>
/// </remarks>
internal sealed partial class MainWindowViewModel(
TerminalWorkspace workspace,
IKnownHostStore knownHosts) : ObservableObject
internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisposable
{
[ObservableProperty]
private string host = "127.0.0.1";
private readonly ClientPaths paths;
private readonly ClientCacheFactory caches;
private readonly TerminalWorkspace workspace;
private readonly IKnownHostStore knownHosts;
private readonly SignInHandler signIn;
private readonly TimeProvider clock;
private readonly Argon2Profile? passphraseProfile;
private IVaultServer? connection;
private bool disposed;
/// <summary>
/// Establishes a connection to a server.
/// </summary>
/// <remarks>
/// A delegate rather than a direct call to <see cref="ServerConnection.SignInAsync"/>, so this whole
/// state machine can be driven by a test against an in-memory server. Sign-in is the one step that
/// genuinely needs a browser and a network, and letting it be the reason nothing else is testable
/// would be the wrong trade.
/// </remarks>
internal delegate Task<IVaultServer> SignInHandler(Uri serverUrl, CancellationToken cancellationToken);
internal MainWindowViewModel(
ClientPaths paths,
ClientCacheFactory caches,
TerminalWorkspace workspace,
IKnownHostStore knownHosts,
SignInHandler signIn,
TimeProvider clock,
Argon2Profile? passphraseProfile = null)
{
this.paths = paths;
this.caches = caches;
this.workspace = workspace;
this.knownHosts = knownHosts;
this.signIn = signIn;
this.clock = clock;
this.passphraseProfile = passphraseProfile;
}
[ObservableProperty]
private int port = 22;
private ShellState state = ShellState.Starting;
[ObservableProperty]
private string username = string.Empty;
private string statusMessage = "Opening the local cache…";
[ObservableProperty]
private string password = string.Empty;
private bool isBusy;
[ObservableProperty]
private string status = "Enter a host and connect.";
private string serverUrl = "https://localhost:7217";
[ObservableProperty]
private bool isConnecting;
private string passphrase = string.Empty;
/// <summary>The key awaiting the user's decision, or null when there is none.</summary>
[ObservableProperty]
private HostKeyPresentation? pendingHostKey;
private string confirmPassphrase = string.Empty;
/// <summary>Set when a pinned key changed, which is a dead end rather than a prompt.</summary>
/// <summary>Shown once, immediately after enrolling, and never stored anywhere.</summary>
[ObservableProperty]
private string? hostKeyMismatch;
private string? recoveryCode;
[ObservableProperty]
private bool recoveryCodeWrittenDown;
/// <summary>Who this machine is enrolled as, readable without the passphrase.</summary>
[ObservableProperty]
private string? accountName;
[ObservableProperty]
private VaultViewModel? vault;
/// <summary>Where the embedded browser should navigate.</summary>
public Uri TerminalPageUrl => workspace.PageUrl;
internal Uri TerminalPageUrl => workspace.PageUrl;
/// <summary>Whether the trust prompt should be visible.</summary>
public bool HasPendingHostKey => PendingHostKey is not null;
internal bool IsStarting => State == ShellState.Starting;
/// <summary>Whether the mismatch banner should be visible.</summary>
public bool HasHostKeyMismatch => HostKeyMismatch is not null;
internal bool IsNeedingServer => State == ShellState.NeedsServer;
[RelayCommand]
private async Task ConnectAsync(CancellationToken cancellationToken)
internal bool IsNeedingEnrollment => State == ShellState.NeedsEnrollment;
internal bool IsShowingRecoveryCode => State == ShellState.ShowingRecoveryCode;
internal bool IsLocked => State == ShellState.Locked;
internal bool IsUnlocked => State == ShellState.Unlocked;
/// <summary>Whether a connection to the server is currently held.</summary>
internal bool IsOnline => connection is not null;
/// <summary>
/// Brings the schema up to date and works out which screen to show.
/// </summary>
/// <remarks>
/// Migrating happens before unlock and touches no encrypted content — only the shape of the tables.
/// That is the point of migrating rather than recreating: a user who upgrades while offline must still
/// be able to open their vault.
/// </remarks>
internal async Task StartAsync(CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(Username))
{
Status = "A username is required.";
return;
}
IsConnecting = true;
PendingHostKey = null;
HostKeyMismatch = null;
Status = $"Connecting to {Host}:{Port}…";
try
{
// The renderer has to be attached first: the transport drops frames when nothing is
// connected, so a session opened earlier would lose its SessionOpened frame and then
// stream output at a terminal that was never created.
await workspace.WaitForRendererAsync().ConfigureAwait(true);
paths.EnsureCreated();
await caches.MigrateAsync(cancellationToken).ConfigureAwait(true);
var request = new SshConnectionRequest(
Host,
Port,
Username,
new SshPasswordCredential(Password));
var profile = await Opener().ReadProfileAsync(cancellationToken).ConfigureAwait(true);
await workspace
.OpenSessionAsync(request, TerminalSize.Default, cancellationToken)
.ConfigureAwait(true);
if (profile is null)
{
State = ShellState.NeedsServer;
StatusMessage = "Sign in to a DodoSSH server to set this machine up.";
return;
}
Status = $"Connected to {Host}:{Port}.";
}
catch (SshHostKeyUnknownException exception)
{
// First contact. The user has to decide, and they need the fingerprint to do it.
PendingHostKey = exception.Presentation;
Status = "This host has not been seen before.";
}
catch (SshHostKeyMismatchException exception)
{
HostKeyMismatch = exception.Message;
Status = "The host key has changed. The connection was refused.";
AccountName = profile.DisplayName ?? profile.Email ?? profile.Subject;
ServerUrl = profile.ServerUrl;
State = ShellState.Locked;
StatusMessage = $"Enrolled against {profile.ServerUrl}.";
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
Status = exception.Message;
}
finally
{
IsConnecting = false;
State = ShellState.NeedsServer;
StatusMessage = $"The local cache could not be opened: {exception.Message}";
}
}
/// <summary>Pins the offered key and retries.</summary>
/// <summary>Discovers the server and runs the browser sign-in.</summary>
[RelayCommand]
private async Task TrustHostKeyAsync(CancellationToken cancellationToken)
private async Task SignInAsync(CancellationToken cancellationToken)
{
if (PendingHostKey is not { } presentation)
if (!Uri.TryCreate(ServerUrl, UriKind.Absolute, out var url))
{
StatusMessage = "That is not a valid server URL.";
return;
}
await RunAsync(
"Opening your browser to sign in…",
async () =>
{
connection?.Dispose();
connection = null;
connection = await signIn(url, cancellationToken).ConfigureAwait(true);
OnPropertyChanged(nameof(IsOnline));
var outcome = await Provisioner()!
.RefreshAsync(ServerUrl, cancellationToken)
.ConfigureAwait(true);
AccountName = outcome.Me.DisplayName ?? outcome.Me.Email ?? outcome.Me.Subject;
StatusMessage = outcome.Message;
State = outcome.Status == ProvisionStatus.EnrollmentRequired
? ShellState.NeedsEnrollment
: ShellState.Locked;
}).ConfigureAwait(true);
}
/// <summary>Creates the identity key and the personal vault.</summary>
[RelayCommand]
private async Task EnrollAsync(CancellationToken cancellationToken)
{
if (Provisioner() is not { } provisioner)
{
StatusMessage = "Sign in first.";
return;
}
if (!ValidateNewPassphrase())
{
return;
}
await knownHosts.TrustAsync(presentation, cancellationToken).ConfigureAwait(true);
await RunAsync(
"Creating your vault. This deliberately takes a moment…",
async () =>
{
var chosen = Passphrase;
PendingHostKey = null;
var outcome = await Task
.Run(
() => provisioner.EnrollAsync(
ServerUrl,
chosen,
Environment.MachineName,
"Personal",
cancellationToken),
cancellationToken)
.ConfigureAwait(true);
await ConnectAsync(cancellationToken).ConfigureAwait(true);
ConfirmPassphrase = string.Empty;
RecoveryCode = outcome.RecoveryCode;
RecoveryCodeWrittenDown = false;
StatusMessage = outcome.Message;
// A brand-new account always yields a code. An account someone else already enrolled does
// not, and there is nothing to show.
State = RecoveryCode is null ? ShellState.Locked : ShellState.ShowingRecoveryCode;
}).ConfigureAwait(true);
}
/// <summary>Dismisses the trust prompt without pinning anything.</summary>
/// <summary>Leaves the recovery-code screen, once the user says they have it.</summary>
[RelayCommand]
private void RejectHostKey()
private void ConfirmRecoveryCode()
{
PendingHostKey = null;
Status = "The host key was not trusted, so nothing was connected.";
if (!RecoveryCodeWrittenDown)
{
StatusMessage = "Confirm you have written the recovery code down first.";
return;
}
// Cleared from memory as well as from the screen. It was never persisted, and keeping it in a view
// model for the rest of the session would undo that.
RecoveryCode = null;
State = ShellState.Locked;
StatusMessage = "Unlock with the passphrase you just chose.";
}
partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
OnPropertyChanged(nameof(HasPendingHostKey));
/// <summary>Opens the vault.</summary>
[RelayCommand]
private async Task UnlockAsync(CancellationToken cancellationToken)
{
if (Passphrase.Length == 0)
{
StatusMessage = "Enter your vault passphrase.";
return;
}
partial void OnHostKeyMismatchChanged(string? value) =>
OnPropertyChanged(nameof(HasHostKeyMismatch));
await RunAsync(
"Unlocking…",
async () =>
{
var entered = Passphrase;
// Off the UI thread: Argon2id at the shipped profile is a third of a second of solid CPU
// and would otherwise freeze the window mid-unlock.
var outcome = await Task
.Run(() => Opener().UnlockAsync(entered, cancellationToken), cancellationToken)
.ConfigureAwait(true);
StatusMessage = outcome.Message;
if (!outcome.IsUnlocked)
{
return;
}
Passphrase = string.Empty;
Vault = new VaultViewModel(outcome.Session!, workspace, knownHosts, () => connection);
State = ShellState.Unlocked;
await Vault.LoadAsync(cancellationToken).ConfigureAwait(true);
}).ConfigureAwait(true);
}
/// <summary>Closes the vault and forgets every key it held.</summary>
[RelayCommand]
private async Task LockAsync()
{
if (Vault is { } open)
{
Vault = null;
await open.DisposeAsync().ConfigureAwait(true);
}
State = ShellState.Locked;
StatusMessage = "Locked.";
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (disposed)
{
return;
}
disposed = true;
if (Vault is { } open)
{
await open.DisposeAsync().ConfigureAwait(false);
}
connection?.Dispose();
}
/// <remarks>
/// The passphrase is the entire defence for the vault — docs/crypto.md §2 says so plainly, and no
/// server-side reset exists. A length floor is a crude check and still the one that matters most.
/// </remarks>
private bool ValidateNewPassphrase()
{
if (Passphrase.Length < 12)
{
StatusMessage = "Use a passphrase of at least 12 characters.";
return false;
}
if (!string.Equals(Passphrase, ConfirmPassphrase, StringComparison.Ordinal))
{
StatusMessage = "The two passphrases do not match.";
return false;
}
return true;
}
/// <remarks>
/// Sync limits come from the server when there is one, so a batch is never larger than this
/// particular deployment accepts. Offline, the defaults apply and nothing is pushed anyway.
/// </remarks>
private SessionOpener Opener() => new(caches, clock, connection?.SyncOptions);
private AccountProvisioner? Provisioner() =>
connection is null
? null
: new AccountProvisioner(
connection.Account, connection.KeyBinding, caches, clock, passphraseProfile);
/// <remarks>
/// Every command funnels through here so the busy flag and the failure message are handled once. A
/// command that forgot either would leave the window permanently disabled or silently doing nothing.
/// </remarks>
private async Task RunAsync(string busyMessage, Func<Task> work)
{
if (IsBusy)
{
return;
}
IsBusy = true;
StatusMessage = busyMessage;
try
{
await work().ConfigureAwait(true);
}
catch (OperationCanceledException)
{
StatusMessage = "Cancelled.";
}
catch (Exception exception)
{
StatusMessage = exception.Message;
}
finally
{
IsBusy = false;
}
}
partial void OnStateChanged(ShellState value)
{
OnPropertyChanged(nameof(IsStarting));
OnPropertyChanged(nameof(IsNeedingServer));
OnPropertyChanged(nameof(IsNeedingEnrollment));
OnPropertyChanged(nameof(IsShowingRecoveryCode));
OnPropertyChanged(nameof(IsLocked));
OnPropertyChanged(nameof(IsUnlocked));
}
}
@@ -0,0 +1,550 @@
using System.Collections.ObjectModel;
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Sync;
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.App.ViewModels;
/// <summary>One host, as a row in the list.</summary>
/// <remarks>
/// Carries the decrypted <see cref="HostSecret"/> so opening the editor needs no second decryption, and
/// the flags the list has to show: an edit this machine has not pushed, a change the server refused, and
/// an item a newer client wrote that must not be re-encoded here.
/// </remarks>
internal sealed class HostRowViewModel(VaultHost host)
{
internal Guid EntityId => host.EntityId;
internal HostSecret Host => host.Host;
internal string Label => host.Host.Label;
internal string Address => string.Create(
CultureInfo.InvariantCulture,
$"{host.Host.Username ?? ""}@{host.Host.Hostname}:{host.Host.Port}");
internal bool HasUnsyncedChanges => host.HasUnsyncedChanges;
internal bool IsBlocked => host.IsBlocked;
internal bool IsReadOnly => host.IsReadOnly;
/// <summary>A short marker for the row, so the list says what it knows without a tooltip.</summary>
internal string Badge => host switch
{
{ IsBlocked: true } => "rejected",
{ IsReadOnly: true } => "newer version",
{ HasUnsyncedChanges: true } => "not synced",
_ => string.Empty,
};
}
/// <summary>A conflict, as a row.</summary>
internal sealed class ConflictRowViewModel(ConflictNotice notice)
{
internal Guid Id => notice.Id;
internal string Summary => notice.Summary;
/// <summary>
/// The overridden values, one line each.
/// </summary>
/// <remarks>
/// This is the whole justification for resolving a conflict automatically. If these were not shown,
/// the merge would be last-writer-wins with a longer explanation.
/// </remarks>
internal string Detail => notice.Fields.Count == 0
? string.Empty
: string.Join(
Environment.NewLine,
// Not named 'field': C# 14 made that a contextual keyword inside a property accessor, and
// this whole expression is one.
notice.Fields.Select(entry => entry.DiscardedWasRemoval
? $"{entry.Field}: a removal was overridden; '{entry.Kept}' was kept"
: $"{entry.Field}: kept '{entry.Kept}', discarded '{entry.Discarded}'"));
internal bool HasDetail => notice.Fields.Count > 0;
}
/// <summary>
/// An open vault: the host list, the editor, syncing, and connecting a terminal.
/// </summary>
/// <remarks>
/// <para>
/// The list is the local mirror with unpushed changes laid over it, so an edit appears immediately and a
/// delete disappears immediately whether or not the network is there. Syncing is a separate, explicit
/// action; nothing here blocks on a server.
/// </para>
/// <para>
/// <b>Credentials are not in the vault yet.</b> <c>SyncEntityType.Credential</c> exists in the contract
/// but is not synced, so connecting still asks for a password each time. That is a real M1 limitation
/// rather than a design choice, and the interface says so rather than implying the vault holds more than
/// it does.
/// </para>
/// </remarks>
internal sealed partial class VaultViewModel(
VaultSession session,
TerminalWorkspace workspace,
IKnownHostStore knownHosts,
Func<IVaultServer?> connection) : ObservableObject, IAsyncDisposable
{
private bool disposed;
/// <summary>The hosts to show, unpushed local state included.</summary>
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
/// <summary>Whatever the merge had to override and the user has not acknowledged.</summary>
internal ObservableCollection<ConflictRowViewModel> Conflicts { get; } = [];
internal string VaultName =>
session.Vaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId)?.Name ?? "Vault";
[ObservableProperty]
private HostRowViewModel? selectedHost;
[ObservableProperty]
private string status = string.Empty;
[ObservableProperty]
private int pendingChanges;
[ObservableProperty]
private int unreadableItems;
[ObservableProperty]
private bool isBusy;
// ---- The editor ----
[ObservableProperty]
private bool isEditing;
[ObservableProperty]
private string editorLabel = string.Empty;
[ObservableProperty]
private string editorHostname = string.Empty;
[ObservableProperty]
private int editorPort = HostSecret.DefaultPort;
[ObservableProperty]
private string editorUsername = string.Empty;
[ObservableProperty]
private string editorNotes = string.Empty;
[ObservableProperty]
private bool editorRelayEnabled;
/// <summary>The item being edited, or null when creating.</summary>
private Guid? editingEntityId;
// ---- Connecting ----
/// <remarks>
/// Typed per connection because credentials are not a synced entity type yet. Never persisted.
/// </remarks>
[ObservableProperty]
private string connectPassword = string.Empty;
[ObservableProperty]
private HostKeyPresentation? pendingHostKey;
[ObservableProperty]
private string? hostKeyMismatch;
internal bool HasPendingHostKey => PendingHostKey is not null;
internal bool HasHostKeyMismatch => HostKeyMismatch is not null;
internal bool HasConflicts => Conflicts.Count > 0;
/// <summary>Reads the vault into the list.</summary>
internal async Task LoadAsync(CancellationToken cancellationToken)
{
var listing = await session.Hosts
.ListAsync(session.ActiveVaultId, cancellationToken)
.ConfigureAwait(true);
var selectedId = SelectedHost?.EntityId;
Hosts.Clear();
foreach (var host in listing.Hosts.OrderBy(host => host.Host.Label, StringComparer.CurrentCulture))
{
Hosts.Add(new HostRowViewModel(host));
}
// Selection survives a reload. Losing it on every sync would move the terminal's target out from
// under the user.
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == selectedId) ?? Hosts.FirstOrDefault();
UnreadableItems = listing.Unreadable;
PendingChanges = await session.PendingChangeCountAsync(cancellationToken).ConfigureAwait(true);
await LoadConflictsAsync(cancellationToken).ConfigureAwait(true);
Status = Hosts.Count == 0
? "No hosts yet. Add one."
: $"{Hosts.Count} host(s) in {VaultName}.";
}
/// <summary>Runs a synchronisation pass, if there is a server to talk to.</summary>
[RelayCommand]
private async Task SyncAsync(CancellationToken cancellationToken)
{
if (connection() is not { } server)
{
Status = "Offline. Changes are queued and will be sent after you sign in.";
return;
}
await RunAsync(
"Synchronising…",
async () =>
{
var report = await session.SyncAsync(server.Sync, cancellationToken).ConfigureAwait(true);
await LoadAsync(cancellationToken).ConfigureAwait(true);
Status = Describe(report);
}).ConfigureAwait(true);
}
/// <summary>Starts a new host.</summary>
[RelayCommand]
private void NewHost()
{
editingEntityId = null;
EditorLabel = string.Empty;
EditorHostname = string.Empty;
EditorPort = HostSecret.DefaultPort;
EditorUsername = string.Empty;
EditorNotes = string.Empty;
EditorRelayEnabled = false;
IsEditing = true;
Status = "Adding a host.";
}
/// <summary>Opens the selected host for editing.</summary>
[RelayCommand]
private void EditSelectedHost()
{
if (SelectedHost is not { } row)
{
return;
}
if (row.IsReadOnly)
{
// Re-encoding would drop fields this build has no concept of, so the honest answer is to
// refuse rather than to silently lose a colleague's data.
Status = "This host was written by a newer version of DodoSSH. Update before editing it.";
return;
}
editingEntityId = row.EntityId;
EditorLabel = row.Host.Label;
EditorHostname = row.Host.Hostname;
EditorPort = row.Host.Port;
EditorUsername = row.Host.Username ?? string.Empty;
EditorNotes = row.Host.Notes ?? string.Empty;
EditorRelayEnabled = row.Host.RelayEnabled;
IsEditing = true;
Status = $"Editing {row.Label}.";
}
/// <summary>Abandons the editor.</summary>
[RelayCommand]
private void CancelEdit()
{
IsEditing = false;
editingEntityId = null;
Status = string.Empty;
}
/// <summary>Stores the editor's contents, encrypted, and queues it for the server.</summary>
[RelayCommand]
private async Task SaveHostAsync(CancellationToken cancellationToken)
{
var host = BuildHost();
if (!host.TryValidate(out var error))
{
Status = error;
return;
}
await RunAsync(
"Saving…",
async () =>
{
if (editingEntityId is { } entityId)
{
await session.Hosts
.UpdateAsync(session.ActiveVaultId, entityId, host, cancellationToken)
.ConfigureAwait(true);
}
else
{
editingEntityId = await session.Hosts
.CreateAsync(session.ActiveVaultId, host, cancellationToken)
.ConfigureAwait(true);
}
IsEditing = false;
await LoadAsync(cancellationToken).ConfigureAwait(true);
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == editingEntityId);
editingEntityId = null;
Status = $"Saved '{host.Label}'. It will sync when you are online.";
}).ConfigureAwait(true);
}
/// <summary>Queues a tombstone for the selected host.</summary>
[RelayCommand]
private async Task DeleteHostAsync(CancellationToken cancellationToken)
{
if (SelectedHost is not { } row)
{
return;
}
await RunAsync(
"Deleting…",
async () =>
{
await session.Hosts
.DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken)
.ConfigureAwait(true);
await LoadAsync(cancellationToken).ConfigureAwait(true);
Status = $"Deleted '{row.Label}'.";
}).ConfigureAwait(true);
}
/// <summary>Opens a terminal on the selected host.</summary>
[RelayCommand]
private async Task ConnectAsync(CancellationToken cancellationToken)
{
if (SelectedHost is not { } row)
{
Status = "Choose a host first.";
return;
}
if (string.IsNullOrEmpty(row.Host.Username))
{
Status = "This host has no username. Edit it and add one.";
return;
}
PendingHostKey = null;
HostKeyMismatch = null;
await RunAsync(
$"Connecting to {row.Label}…",
() => OpenSessionAsync(row, cancellationToken)).ConfigureAwait(true);
}
/// <summary>Pins the offered host key and retries.</summary>
[RelayCommand]
private async Task TrustHostKeyAsync(CancellationToken cancellationToken)
{
if (PendingHostKey is not { } presentation)
{
return;
}
await knownHosts.TrustAsync(presentation, cancellationToken).ConfigureAwait(true);
PendingHostKey = null;
await ConnectAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>Dismisses the trust prompt without pinning anything.</summary>
[RelayCommand]
private void RejectHostKey()
{
PendingHostKey = null;
Status = "The host key was not trusted, so nothing was connected.";
}
/// <summary>
/// Marks every shown conflict as seen.
/// </summary>
/// <remarks>
/// Acknowledged rather than deleted, so the discarded values stay retrievable afterwards. Someone who
/// dismisses this and realises a minute later that they wanted the other value should still be able to
/// get it.
/// </remarks>
[RelayCommand]
private async Task AcknowledgeAllConflictsAsync(CancellationToken cancellationToken)
{
foreach (var conflict in Conflicts.ToArray())
{
await session.AcknowledgeConflictAsync(conflict.Id, cancellationToken).ConfigureAwait(true);
}
await LoadConflictsAsync(cancellationToken).ConfigureAwait(true);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (disposed)
{
return;
}
disposed = true;
await session.DisposeAsync().ConfigureAwait(false);
}
/// <remarks>
/// The renderer has to be attached before a session opens: the transport drops frames when nothing is
/// connected, so a session opened earlier would lose its <c>SessionOpened</c> frame and then stream
/// output at a terminal that was never created.
/// </remarks>
private async Task OpenSessionAsync(HostRowViewModel row, CancellationToken cancellationToken)
{
try
{
await workspace.WaitForRendererAsync().ConfigureAwait(true);
var request = new SshConnectionRequest(
row.Host.Hostname,
row.Host.Port,
row.Host.Username!,
new SshPasswordCredential(ConnectPassword));
await workspace
.OpenSessionAsync(request, TerminalSize.Default, cancellationToken)
.ConfigureAwait(true);
Status = $"Connected to {row.Label}.";
}
catch (SshHostKeyUnknownException exception)
{
// First contact. The user has to decide, and they need the fingerprint to do it.
PendingHostKey = exception.Presentation;
Status = "This host has not been seen before.";
}
catch (SshHostKeyMismatchException exception)
{
HostKeyMismatch = exception.Message;
Status = "The host key has changed. The connection was refused.";
}
}
private HostSecret BuildHost() =>
new()
{
Label = EditorLabel.Trim(),
Hostname = EditorHostname.Trim(),
Port = EditorPort,
Username = string.IsNullOrWhiteSpace(EditorUsername) ? null : EditorUsername.Trim(),
Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes,
RelayEnabled = EditorRelayEnabled,
};
private async Task LoadConflictsAsync(CancellationToken cancellationToken)
{
var notices = await session.ReadConflictsAsync(cancellationToken).ConfigureAwait(true);
Conflicts.Clear();
foreach (var notice in notices)
{
Conflicts.Add(new ConflictRowViewModel(notice));
}
OnPropertyChanged(nameof(HasConflicts));
}
/// <remarks>
/// Deliberately reports the things a user has to act on rather than a count of successes. A pass that
/// resurrected a host or parked a change looks identical to a quiet one otherwise, and the whole point
/// of recording those is that somebody sees them.
/// </remarks>
private static string Describe(SyncReport report)
{
if (!report.NeedsAttention)
{
return report.Pulled == 0 && report.Pushed == 0
? "Already up to date."
: $"Synchronised: {report.Pulled} in, {report.Pushed} out.";
}
var notes = new List<string>();
if (report.Resurrected > 0)
{
notes.Add($"{report.Resurrected} host(s) deleted elsewhere were kept under a new name");
}
if (report.DeletesAbandoned > 0)
{
notes.Add($"{report.DeletesAbandoned} deletion(s) were not applied because of a newer edit");
}
if (report.Parked > 0)
{
notes.Add($"{report.Parked} change(s) were refused and need attention");
}
if (report.Unreadable > 0)
{
notes.Add($"{report.Unreadable} item(s) could not be decrypted");
}
if (report.RekeyRequired)
{
notes.Add("this vault was rekeyed and your access needs re-issuing");
}
return "Synchronised, but: " + string.Join("; ", notes) + ".";
}
private async Task RunAsync(string busyMessage, Func<Task> work)
{
if (IsBusy)
{
return;
}
IsBusy = true;
Status = busyMessage;
try
{
await work().ConfigureAwait(true);
}
catch (OperationCanceledException)
{
Status = "Cancelled.";
}
catch (Exception exception)
{
Status = exception.Message;
}
finally
{
IsBusy = false;
}
}
partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
OnPropertyChanged(nameof(HasPendingHostKey));
partial void OnHostKeyMismatchChanged(string? value) =>
OnPropertyChanged(nameof(HasHostKeyMismatch));
}
+281 -62
View File
@@ -4,72 +4,291 @@
x:Class="DodoSSH.Client.App.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Title="DodoSSH"
Width="1100"
Height="720"
MinWidth="640"
MinHeight="400"
Width="1180"
Height="760"
MinWidth="820"
MinHeight="520"
Background="#10131a">
<Grid RowDefinitions="Auto,Auto,*">
<Window.Styles>
<Style Selector="TextBlock.hint">
<Setter Property="Foreground" Value="#7b8394" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="TextBlock.heading">
<Setter Property="Foreground" Value="#e6e9f0" />
<Setter Property="FontSize" Value="18" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="Border.card">
<Setter Property="Background" Value="#171b24" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="24" />
<Setter Property="MaxWidth" Value="520" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Center" />
</Style>
</Window.Styles>
<!--
The terminal's WebView stays in the visual tree at all times and is covered by the setup and unlock
screens rather than being collapsed. A NativeWebView hosts a real child window, and hiding it means
never realising it — which would leave the terminal blank on the first connection after unlocking.
-->
<Panel>
<Grid RowDefinitions="Auto,*" ColumnDefinitions="340,*">
<!-- Account bar -->
<Border Grid.Row="0" Grid.ColumnSpan="2" Padding="12,8" Background="#171b24"
IsVisible="{Binding IsUnlocked}">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<TextBlock Text="{Binding Vault.VaultName}" Foreground="#e6e9f0" FontWeight="SemiBold"
VerticalAlignment="Center" />
<TextBlock Text="{Binding AccountName}" Classes="hint" VerticalAlignment="Center" />
<TextBlock Text="{Binding Vault.Status}" Classes="hint" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" MaxWidth="520" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<TextBlock Text="offline" Foreground="#c8a55a" VerticalAlignment="Center"
IsVisible="{Binding !IsOnline}" />
<Button Content="Sign in" Command="{Binding SignInCommand}"
IsVisible="{Binding !IsOnline}" />
<Button Content="Sync" Command="{Binding Vault.SyncCommand}" />
<Button Content="Lock" Command="{Binding LockCommand}" />
</StackPanel>
</Grid>
</Border>
<!-- Host list -->
<Grid Grid.Row="1" Grid.Column="0" RowDefinitions="*,Auto,Auto"
Background="#131722" IsVisible="{Binding IsUnlocked}">
<ListBox Grid.Row="0" Margin="6"
ItemsSource="{Binding Vault.Hosts}"
SelectedItem="{Binding Vault.SelectedHost}"
Background="Transparent">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel">
<StackPanel Spacing="2" Margin="2,4">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Label}" Foreground="#e6e9f0" FontWeight="SemiBold" />
<Border Background="#2b2410" CornerRadius="3" Padding="4,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" Foreground="#e8dcb0" FontSize="10"
VerticalAlignment="Center" />
</Border>
</StackPanel>
<TextBlock Text="{Binding Address}" Classes="hint" FontSize="11"
FontFamily="ui-monospace,Consolas,monospace" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- The editor doubles as the "add" form; there is no separate dialog. -->
<Border Grid.Row="1" Padding="10" Background="#171b24" IsVisible="{Binding Vault.IsEditing}">
<StackPanel Spacing="6">
<TextBox Text="{Binding Vault.EditorLabel}" PlaceholderText="name" />
<TextBox Text="{Binding Vault.EditorHostname}" PlaceholderText="hostname or address" />
<NumericUpDown Value="{Binding Vault.EditorPort}" Minimum="1" Maximum="65535"
FormatString="0" />
<TextBox Text="{Binding Vault.EditorUsername}" PlaceholderText="username" />
<TextBox Text="{Binding Vault.EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="60" TextWrapping="Wrap" />
<CheckBox IsChecked="{Binding Vault.EditorRelayEnabled}"
Content="Allow connecting through the server relay" />
<!--
Stated at the moment the decision is made, which is the only place it means anything. With
relay off the server stores no address at all; with it on the server must be able to resolve
the target, or it becomes an authenticated open proxy into the operator's network.
-->
<TextBlock Classes="hint" FontSize="11"
Text="Enabling the relay stores this host's address on the server in plain text. Everything else about the host stays encrypted." />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Save" Command="{Binding Vault.SaveHostCommand}" />
<Button Content="Cancel" Command="{Binding Vault.CancelEditCommand}" />
</StackPanel>
</StackPanel>
</Border>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="6" Margin="8"
IsVisible="{Binding !Vault.IsEditing}">
<Button Content="Add" Command="{Binding Vault.NewHostCommand}" />
<Button Content="Edit" Command="{Binding Vault.EditSelectedHostCommand}" />
<Button Content="Delete" Command="{Binding Vault.DeleteHostCommand}" />
</StackPanel>
</Grid>
<!-- Terminal column -->
<Grid Grid.Row="1" Grid.Column="1" RowDefinitions="Auto,Auto,*">
<Border Grid.Row="0" Padding="10,8" Background="#171b24" IsVisible="{Binding IsUnlocked}">
<StackPanel Orientation="Horizontal" Spacing="8">
<!--
Typed per connection. SyncEntityType.Credential exists in the contract but is not synced
yet, so the vault genuinely does not hold this — saying so beats a password box that looks
like it should have been remembered.
-->
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored yet)"
PasswordChar="•" Width="220" VerticalAlignment="Center" />
<Button Content="Connect" Command="{Binding Vault.ConnectCommand}"
IsEnabled="{Binding !Vault.IsBusy}" VerticalAlignment="Center" />
<TextBlock Classes="hint" FontSize="11" VerticalAlignment="Center"
Text="Credentials are not in the vault yet." />
</StackPanel>
</Border>
<StackPanel Grid.Row="1" IsVisible="{Binding IsUnlocked}">
<!--
Host key prompts. Unknown and changed look deliberately different: one is a decision, the
other is a refusal. Presenting a changed key with a "continue" button is how users are taught
to click through the one warning that matters.
-->
<Border Padding="10,8" Background="#2b2410" IsVisible="{Binding Vault.HasPendingHostKey}">
<StackPanel Spacing="6">
<TextBlock Text="This host has not been seen before. Check the fingerprint against what the server's operator published."
Foreground="#e8dcb0" TextWrapping="Wrap" />
<SelectableTextBlock Text="{Binding Vault.PendingHostKey.Fingerprint}"
FontFamily="ui-monospace,Consolas,monospace"
Foreground="#f4ecd0" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Trust and connect" Command="{Binding Vault.TrustHostKeyCommand}" />
<Button Content="Cancel" Command="{Binding Vault.RejectHostKeyCommand}" />
</StackPanel>
</StackPanel>
</Border>
<Border Padding="10,8" Background="#3a1418" IsVisible="{Binding Vault.HasHostKeyMismatch}">
<StackPanel Spacing="6">
<TextBlock Text="The host key changed and the connection was refused."
Foreground="#f3c9cd" FontWeight="SemiBold" />
<SelectableTextBlock Text="{Binding Vault.HostKeyMismatch}"
Foreground="#f3c9cd" TextWrapping="Wrap" />
<TextBlock Text="If the server was legitimately rebuilt, remove its pinned key in the host's settings first. There is deliberately no way to continue from here."
Foreground="#d59aa1" TextWrapping="Wrap" />
</StackPanel>
</Border>
<!--
The conflict log. The merge is only allowed to pick a winner because the value it overrode is
kept and shown; without this panel it would be last-writer-wins with a longer explanation.
-->
<Border Padding="10,8" Background="#1b2432" IsVisible="{Binding Vault.HasConflicts}">
<StackPanel Spacing="6">
<TextBlock Text="Some changes could not be merged automatically."
Foreground="#bcd2ea" FontWeight="SemiBold" />
<ItemsControl ItemsSource="{Binding Vault.Conflicts}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConflictRowViewModel">
<Border Margin="0,4" Padding="8" Background="#141b26" CornerRadius="4">
<StackPanel Spacing="4">
<TextBlock Text="{Binding Summary}" Foreground="#dfe6f0" TextWrapping="Wrap" />
<SelectableTextBlock Text="{Binding Detail}" Classes="hint" FontSize="11"
FontFamily="ui-monospace,Consolas,monospace"
IsVisible="{Binding HasDetail}" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Content="Dismiss all" Command="{Binding Vault.AcknowledgeAllConflictsCommand}"
HorizontalAlignment="Left" />
</StackPanel>
</Border>
</StackPanel>
<!--
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate browser process
tree, so twenty tabs would cost twenty of them.
-->
<NativeWebView Grid.Row="2" x:Name="Terminal" />
</Grid>
</Grid>
<!-- Setup and unlock, over the top. -->
<Border Background="#10131a" IsVisible="{Binding !IsUnlocked}">
<Panel>
<Border Classes="card" IsVisible="{Binding IsStarting}">
<StackPanel Spacing="10">
<TextBlock Classes="heading" Text="DodoSSH" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
</StackPanel>
</Border>
<Border Classes="card" IsVisible="{Binding IsNeedingServer}">
<StackPanel Spacing="12">
<TextBlock Classes="heading" Text="Connect to your server" />
<TextBlock Classes="hint"
Text="One address is all this needs. The identity provider, the client id and the scopes all come from the server itself." />
<TextBox Text="{Binding ServerUrl}" PlaceholderText="https://dodossh.example" />
<Button Content="Sign in with your browser" Command="{Binding SignInCommand}"
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
</StackPanel>
</Border>
<Border Classes="card" IsVisible="{Binding IsNeedingEnrollment}">
<StackPanel Spacing="12">
<TextBlock Classes="heading" Text="Choose a vault passphrase" />
<TextBlock Classes="hint"
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your vault." />
<TextBox Text="{Binding Passphrase}" PlaceholderText="passphrase" PasswordChar="•" />
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•" />
<Button Content="Create my vault" Command="{Binding EnrollCommand}"
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
</StackPanel>
</Border>
<!--
Shown once and impossible to skip. This is the only moment the code exists, and losing it
together with the passphrase means the vault is unrecoverable — there is no server-side reset by
design.
-->
<Border Classes="card" IsVisible="{Binding IsShowingRecoveryCode}">
<StackPanel Spacing="12">
<TextBlock Classes="heading" Text="Write this recovery code down" />
<TextBlock Classes="hint"
Text="It is shown once and is not stored anywhere. Without it, forgetting your passphrase means losing the vault: nobody — including whoever runs the server — can recover it for you." />
<Border Background="#0c0f15" CornerRadius="6" Padding="14">
<SelectableTextBlock Text="{Binding RecoveryCode}"
FontFamily="ui-monospace,Consolas,monospace"
FontSize="16" Foreground="#9ee6b4" TextWrapping="Wrap" />
</Border>
<CheckBox IsChecked="{Binding RecoveryCodeWrittenDown}"
Content="I have written it down somewhere safe" />
<Button Content="Continue" Command="{Binding ConfirmRecoveryCodeCommand}"
HorizontalAlignment="Left" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
</StackPanel>
</Border>
<Border Classes="card" IsVisible="{Binding IsLocked}">
<StackPanel Spacing="12">
<TextBlock Classes="heading" Text="Unlock your vault" />
<TextBlock Text="{Binding AccountName}" Foreground="#bcd2ea" />
<TextBox Text="{Binding Passphrase}" PlaceholderText="vault passphrase" PasswordChar="•" />
<Button Content="Unlock" Command="{Binding UnlockCommand}"
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
<TextBlock Classes="hint" FontSize="11"
Text="This works with no network: the salt and the wrapped key are already on this machine." />
</StackPanel>
</Border>
</Panel>
<!-- Connection bar. Replaced by the host list once the vault is wired up. -->
<Border Grid.Row="0" Padding="10,8" Background="#171b24">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox Text="{Binding Host}" PlaceholderText="host" Width="200" VerticalAlignment="Center" />
<NumericUpDown Value="{Binding Port}" Minimum="1" Maximum="65535"
FormatString="0" Width="110" VerticalAlignment="Center" />
<TextBox Text="{Binding Username}" PlaceholderText="username" Width="150" VerticalAlignment="Center" />
<TextBox Text="{Binding Password}" PlaceholderText="password" PasswordChar="•"
Width="170" VerticalAlignment="Center" />
<Button Content="Connect"
Command="{Binding ConnectCommand}"
IsEnabled="{Binding !IsConnecting}"
VerticalAlignment="Center" />
<TextBlock Text="{Binding Status}" Foreground="#7b8394"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
</StackPanel>
</Border>
<!--
Host key prompts. Unknown and changed look deliberately different: one is a decision, the
other is a refusal. Presenting a changed key with a "continue" button is how users are taught
to click through the one warning that matters.
-->
<StackPanel Grid.Row="1">
<Border Padding="10,8" Background="#2b2410" IsVisible="{Binding HasPendingHostKey}">
<StackPanel Spacing="6">
<TextBlock Text="This host has not been seen before. Check the fingerprint against what the server's operator published."
Foreground="#e8dcb0" TextWrapping="Wrap" />
<SelectableTextBlock Text="{Binding PendingHostKey.Fingerprint}"
FontFamily="ui-monospace,Consolas,monospace"
Foreground="#f4ecd0" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Trust and connect" Command="{Binding TrustHostKeyCommand}" />
<Button Content="Cancel" Command="{Binding RejectHostKeyCommand}" />
</StackPanel>
</StackPanel>
</Border>
<Border Padding="10,8" Background="#3a1418" IsVisible="{Binding HasHostKeyMismatch}">
<StackPanel Spacing="6">
<TextBlock Text="The host key changed and the connection was refused."
Foreground="#f3c9cd" FontWeight="SemiBold" />
<SelectableTextBlock Text="{Binding HostKeyMismatch}"
Foreground="#f3c9cd" TextWrapping="Wrap" />
<TextBlock Text="If the server was legitimately rebuilt, remove its pinned key in the host's settings first. There is deliberately no way to continue from here."
Foreground="#d59aa1" TextWrapping="Wrap" />
</StackPanel>
</Border>
</StackPanel>
<!--
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate browser
process, so twenty tabs would cost twenty renderer processes.
-->
<NativeWebView Grid.Row="2" x:Name="Terminal" />
</Grid>
</Panel>
</Window>
+250 -5
View File
@@ -191,19 +191,116 @@
"resolved": "0.11.6",
"contentHash": "NdNWGDiZ6eS/Mf/9+QHR91cj1K7Hy+PX9yrHI/zM7xFYuj9IWT2uxtB6sCHjrnxAeLV9fut1R6zHDUGKX6f9lQ=="
},
"Microsoft.Data.Sqlite.Core": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "TPCs0ldm7AWqcKmp6f/Xr+14sat7hx4rHfRlS4RgCURBH2thEWbAKEyX7cCWr63zVJVOJIJZTg2cBiUXa8ys6g==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.EntityFrameworkCore.Abstractions": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA=="
},
"Microsoft.EntityFrameworkCore.Analyzers": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q=="
},
"Microsoft.EntityFrameworkCore.Sqlite.Core": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "YbVWMIouzwTKBiLms8boa7xeRT88wI14R1msv3XExFk9n0/sa8nU7MwDa1CKtfLGMJs7O7QWuS9/xhcQ72AD2A==",
"dependencies": {
"Microsoft.Data.Sqlite.Core": "10.0.10",
"Microsoft.EntityFrameworkCore.Relational": "10.0.10",
"Microsoft.Extensions.Caching.Memory": "10.0.10",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
"Microsoft.Extensions.DependencyModel": "10.0.10",
"Microsoft.Extensions.Logging": "10.0.10",
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.Extensions.Caching.Abstractions": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.10"
}
},
"Microsoft.Extensions.Caching.Memory": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==",
"dependencies": {
"Microsoft.Extensions.Caching.Abstractions": "10.0.10",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
"Microsoft.Extensions.Logging.Abstractions": "10.0.10",
"Microsoft.Extensions.Options": "10.0.10",
"Microsoft.Extensions.Primitives": "10.0.10"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.10"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "8.0.2",
"contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg=="
"resolved": "10.0.10",
"contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA=="
},
"Microsoft.Extensions.DependencyModel": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA=="
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "10.0.10",
"Microsoft.Extensions.Logging.Abstractions": "10.0.10",
"Microsoft.Extensions.Options": "10.0.10"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "8.0.3",
"contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==",
"resolved": "10.0.10",
"contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10"
}
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
"Microsoft.Extensions.Primitives": "10.0.10"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ=="
},
"SkiaSharp": {
"type": "Transitive",
"resolved": "3.119.4",
@@ -238,24 +335,172 @@
"resolved": "0.94.1",
"contentHash": "11YMr7FnAbL83bQmVxlhbIKHvSLxjO81D12Ej0QMSGXMDTxNA9MTOa4MQxx43nv5el/efuPHwzyrj6a5ha2gug=="
},
"dodossh.client.api": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Auth": "[1.0.0, )",
"DodoSSH.Contracts": "[1.0.0, )",
"DodoSSH.Crypto": "[1.0.0, )"
}
},
"dodossh.client.auth": {
"type": "Project"
},
"dodossh.client.domain": {
"type": "Project"
},
"dodossh.client.session": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Api": "[1.0.0, )",
"DodoSSH.Client.Auth": "[1.0.0, )",
"DodoSSH.Client.Domain": "[1.0.0, )",
"DodoSSH.Client.Storage": "[1.0.0, )",
"DodoSSH.Client.Sync": "[1.0.0, )"
}
},
"dodossh.client.ssh": {
"type": "Project",
"dependencies": {
"SSH.NET": "[2025.1.0, )"
}
},
"dodossh.client.storage": {
"type": "Project",
"dependencies": {
"DodoSSH.Contracts": "[1.0.0, )",
"DodoSSH.Crypto": "[1.0.0, )",
"EFCore.NamingConventions": "[10.0.1, )",
"Microsoft.EntityFrameworkCore.Sqlite": "[10.0.10, )"
}
},
"dodossh.client.sync": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Api": "[1.0.0, )",
"DodoSSH.Client.Domain": "[1.0.0, )",
"DodoSSH.Client.Storage": "[1.0.0, )",
"DodoSSH.Contracts": "[1.0.0, )",
"DodoSSH.Crypto": "[1.0.0, )"
}
},
"dodossh.client.terminal": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Ssh": "[1.0.0, )"
}
},
"dodossh.contracts": {
"type": "Project"
},
"dodossh.crypto": {
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )"
}
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"EFCore.NamingConventions": {
"type": "CentralTransitive",
"requested": "[10.0.1, )",
"resolved": "10.0.1",
"contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==",
"dependencies": {
"Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)",
"Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1"
}
},
"libsodium": {
"type": "CentralTransitive",
"requested": "[1.0.22, )",
"resolved": "1.0.22",
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
},
"Microsoft.EntityFrameworkCore": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==",
"dependencies": {
"Microsoft.EntityFrameworkCore.Abstractions": "10.0.10",
"Microsoft.EntityFrameworkCore.Analyzers": "10.0.10",
"Microsoft.Extensions.Caching.Memory": "10.0.10",
"Microsoft.Extensions.Logging": "10.0.10"
}
},
"Microsoft.EntityFrameworkCore.Relational": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==",
"dependencies": {
"Microsoft.EntityFrameworkCore": "10.0.10",
"Microsoft.Extensions.Caching.Memory": "10.0.10",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
"Microsoft.Extensions.Logging": "10.0.10"
}
},
"Microsoft.EntityFrameworkCore.Sqlite": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "kzg9MuQNJvZQxAU+piSkEzc7/1tpW6n1nVSGGMObu2GgxLK8Nf+6fvZundaznTZ+O2KhfPZ8HFNCzMH3PWDUmA==",
"dependencies": {
"Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.10",
"Microsoft.Extensions.Caching.Memory": "10.0.10",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
"Microsoft.Extensions.DependencyModel": "10.0.10",
"Microsoft.Extensions.Logging": "10.0.10",
"SQLitePCLRaw.bundle_e_sqlite3": "2.1.11",
"SQLitePCLRaw.core": "2.1.11"
}
},
"NSec.Cryptography": {
"type": "CentralTransitive",
"requested": "[26.4.0, )",
"resolved": "26.4.0",
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
"dependencies": {
"libsodium": "[1.0.22, 1.0.23)"
}
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "CentralTransitive",
"requested": "[2.1.12, )",
"resolved": "2.1.12",
"contentHash": "mAgscpQMLw5/nfA1Q5oJVAT29yROUo1ifZGbbTpx/lwZpSxMUGoYbKfmvdm8oXER+RzxqBmmQzeBEVKfeHv2nw==",
"dependencies": {
"SQLitePCLRaw.lib.e_sqlite3": "2.1.12",
"SQLitePCLRaw.provider.e_sqlite3": "2.1.12"
}
},
"SQLitePCLRaw.core": {
"type": "CentralTransitive",
"requested": "[2.1.12, )",
"resolved": "2.1.12",
"contentHash": "ETpNw9DY3ckWLgRRAeCHj+GKOuPi61aeczkXhgHexUvqoZBAYg8RYESE2J7O1M7+o6QbdSEZwrw9bfqztUVWXg=="
},
"SQLitePCLRaw.lib.e_sqlite3": {
"type": "CentralTransitive",
"requested": "[2.1.12, )",
"resolved": "2.1.12",
"contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w=="
},
"SQLitePCLRaw.provider.e_sqlite3": {
"type": "CentralTransitive",
"requested": "[2.1.12, )",
"resolved": "2.1.12",
"contentHash": "W3oH4XIfCzFrgUSDKHhN6N+dgzA5YHOR2VxX8GB6Qy7CyrJJgxPEG8NirgYWlPQC5P2jz2knSsexWu4tDUL33g==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.12"
}
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",