Public Access
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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user