Public Access
Writes down that locking the vault leaves shells running, and shows the count on the unlock screen rather than leaving it to be inferred. Conflict resolution: - ShellFlowTests' fixture keeps main's FakeSshConnectionFactory. The branch added an IdleSshConnectionFactory for exactly what main's fake already does — a shell that is open, silent and never closes on its own — so FakeSshConnections.cs is dropped rather than merged, leaving one fake SSH stack in the suite instead of two that would drift apart. - MainWindowViewModel and TerminalWorkspace: both sides added their own members, so both are kept. - TerminalWorkspaceTests was added by both branches, with the renderer gate on one side and session lifetime on the other. Merged into one class over one set of helpers; the gate tests now use FakeConnectionFactory rather than an NSubstitute stub, since the suite already has the fake. gallant's polling Timeout constant is PollTimeout, which no longer reads as the renderer's. - platform-flags.md keeps main's measured focus section and drops the short "nothing hands the terminal keyboard focus" entry the branch still carried, which that section supersedes. One genuine disagreement between the branches, left visible rather than flattened: this branch measured that a collapsed WebView cannot be typed into and attributed it to a hidden WS_CHILD window being ineligible for keyboard focus, while main's focus work measured Win32 focus still held by that hidden window and added a lock path that moves the keyboard off it. Both results stand; the mechanism sentence now defers to the focus entry, which makes the input barrier something the lock path maintains rather than something the platform guarantees. Full suite green, including the container-backed SSH tests.
564 lines
21 KiB
C#
564 lines
21 KiB
C#
using System.Security.Authentication;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
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: get to an unlocked vault, then hand over to <see cref="VaultViewModel"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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>
|
|
/// 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 : ObservableObject, IAsyncDisposable
|
|
{
|
|
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 ShellState state = ShellState.Starting;
|
|
|
|
[ObservableProperty]
|
|
private string statusMessage = "Opening the local cache…";
|
|
|
|
[ObservableProperty]
|
|
private bool isBusy;
|
|
|
|
/// <remarks>
|
|
/// The address <c>dotnet run --project src/DodoSSH.Api</c> actually serves, so the first launch after
|
|
/// a clone works without the user having to know a port. This was <c>https://localhost:7217</c>, which
|
|
/// is the API's <em>second</em> launch profile: the first is HTTP on 5233 and is the one both the
|
|
/// README and a plain <c>dotnet run</c> select, so nothing was listening on 7217. Pointing an HTTPS
|
|
/// client at a plaintext port fails as "The SSL connection could not be established", which sends
|
|
/// people looking for a certificate problem — see <see cref="ExplainSignInFailure" />. A real
|
|
/// deployment is HTTPS behind a proxy and its address is typed over this one; the placeholder in the
|
|
/// setup card shows that shape.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private string serverUrl = "http://localhost:5233";
|
|
|
|
[ObservableProperty]
|
|
private string passphrase = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private string confirmPassphrase = string.Empty;
|
|
|
|
/// <summary>Shown once, immediately after enrolling, and never stored anywhere.</summary>
|
|
[ObservableProperty]
|
|
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>
|
|
/// Shells that were left running when the vault was locked.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Refreshed by <see cref="LockAsync"/>, which is where the policy this reports is explained.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private int liveSessionCount;
|
|
|
|
internal bool HasLiveSessions => LiveSessionCount > 0;
|
|
|
|
/// <summary>The count as a sentence, because a bare number on a lock screen explains nothing.</summary>
|
|
internal string LiveSessionSummary => LiveSessionCount == 1
|
|
? "1 shell is still connected and still running."
|
|
: $"{LiveSessionCount} shells are still connected and still running.";
|
|
|
|
/// <summary>Where the embedded browser should navigate.</summary>
|
|
internal Uri TerminalPageUrl => workspace.PageUrl;
|
|
|
|
/// <summary>
|
|
/// Raised when a terminal session opens, so the view can hand the terminal the keyboard.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Forwarded from <see cref="VaultViewModel.SessionOpened"/> rather than exposed there directly,
|
|
/// because <see cref="Vault"/> is replaced on every unlock and the view would have to re-subscribe
|
|
/// each time. This shell is the window's data context for the life of the process, so one
|
|
/// subscription is enough.
|
|
/// </remarks>
|
|
internal event EventHandler? TerminalSessionOpened;
|
|
|
|
internal bool IsStarting => State == ShellState.Starting;
|
|
|
|
internal bool IsNeedingServer => State == ShellState.NeedsServer;
|
|
|
|
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)
|
|
{
|
|
try
|
|
{
|
|
paths.EnsureCreated();
|
|
await caches.MigrateAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
var profile = await Opener().ReadProfileAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
if (profile is null)
|
|
{
|
|
State = ShellState.NeedsServer;
|
|
StatusMessage = "Sign in to a DodoSSH server to set this machine up.";
|
|
return;
|
|
}
|
|
|
|
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)
|
|
{
|
|
State = ShellState.NeedsServer;
|
|
StatusMessage = $"The local cache could not be opened: {exception.Message}";
|
|
}
|
|
}
|
|
|
|
/// <summary>Discovers the server and runs the browser sign-in.</summary>
|
|
[RelayCommand]
|
|
private async Task SignInAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (!Uri.TryCreate(ServerUrl, UriKind.Absolute, out var url))
|
|
{
|
|
StatusMessage = "That is not a valid server URL.";
|
|
return;
|
|
}
|
|
|
|
// Checked separately from parsing, because "localhost:5233" parses perfectly well as an absolute
|
|
// URI whose scheme is "localhost" — and then fails much later with something unrelated to the
|
|
// actual mistake.
|
|
if (url.Scheme is not ("http" or "https"))
|
|
{
|
|
StatusMessage = $"A server URL has to start with http:// or https://, not {url.Scheme}:.";
|
|
return;
|
|
}
|
|
|
|
await RunAsync(
|
|
"Opening your browser to sign in…",
|
|
explain: exception => ExplainSignInFailure(exception, url),
|
|
work: 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 RunAsync(
|
|
"Creating your vault. This deliberately takes a moment…",
|
|
async () =>
|
|
{
|
|
var chosen = Passphrase;
|
|
|
|
var outcome = await Task
|
|
.Run(
|
|
() => provisioner.EnrollAsync(
|
|
ServerUrl,
|
|
chosen,
|
|
Environment.MachineName,
|
|
"Personal",
|
|
cancellationToken),
|
|
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>Leaves the recovery-code screen, once the user says they have it.</summary>
|
|
[RelayCommand]
|
|
private void ConfirmRecoveryCode()
|
|
{
|
|
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.";
|
|
}
|
|
|
|
/// <summary>Opens the vault.</summary>
|
|
[RelayCommand]
|
|
private async Task UnlockAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (Passphrase.Length == 0)
|
|
{
|
|
StatusMessage = "Enter your vault passphrase.";
|
|
return;
|
|
}
|
|
|
|
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);
|
|
|
|
// After the first load, so the list is on screen before anything talks to a server. The
|
|
// loop is started from the UI thread deliberately: every pass resumes here, which is what
|
|
// keeps the observable collections single-threaded.
|
|
Vault.StartAutoSync();
|
|
}).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Closes the vault and forgets every key it held. Open shells keep running.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Lock is a vault operation, and deliberately not a disconnect.</b> The reason a person locks is
|
|
/// that they are walking away from the machine, which is exactly the moment a long upgrade, build or
|
|
/// transfer is most likely to be in flight — so killing every shell would make Lock a button that
|
|
/// destroys work, and the predictable response is to stop using it and leave the vault open instead.
|
|
/// The same argument decides it for the idle auto-lock this will grow: an unattended timeout that
|
|
/// terminated a running job would be worse than the exposure it removes.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>What "locked" therefore describes.</b> Disposing the vault zeroes the identity keys, the vault
|
|
/// keys and the cache key, so nothing on disk can be read without the passphrase again. It says
|
|
/// nothing about this machine's access to remote hosts: an SSH channel authenticated at connect time
|
|
/// needs no vault key to keep running, and the credential it used was already spent. Locking cannot
|
|
/// retroactively un-authorise a session any more than revocation can — the same honest limit the
|
|
/// README records for a removed team member. So a locked DodoSSH still holds open, authenticated
|
|
/// channels, and <see cref="LiveSessionCount"/> is shown on the unlock screen rather than left to be
|
|
/// inferred from a terminal that the lock screen hides.
|
|
/// </para>
|
|
/// <para>
|
|
/// The count is a snapshot taken here. While locked it can only fall — opening a session needs the
|
|
/// vault — so a stale value over-reports and never under-reports, which is the safe direction for a
|
|
/// warning of this kind.
|
|
/// </para>
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task LockAsync()
|
|
{
|
|
if (Vault is { } open)
|
|
{
|
|
Vault = null;
|
|
await open.DisposeAsync().ConfigureAwait(true);
|
|
}
|
|
|
|
LiveSessionCount = workspace.LiveSessionCount;
|
|
|
|
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>
|
|
/// <param name="busyMessage">Shown while the work runs.</param>
|
|
/// <param name="work">The work.</param>
|
|
/// <param name="explain">
|
|
/// Turns a failure into something a user can act on. Optional, because most failures here already
|
|
/// carry their own explanation; the ones that do not are the ones crossing into another process's
|
|
/// vocabulary, where the exception describes a symptom and not the mistake.
|
|
/// </param>
|
|
private async Task RunAsync(
|
|
string busyMessage,
|
|
Func<Task> work,
|
|
Func<Exception, string>? explain = null)
|
|
{
|
|
if (IsBusy)
|
|
{
|
|
return;
|
|
}
|
|
|
|
IsBusy = true;
|
|
StatusMessage = busyMessage;
|
|
|
|
try
|
|
{
|
|
await work().ConfigureAwait(true);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
StatusMessage = "Cancelled.";
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
StatusMessage = explain?.Invoke(exception) ?? exception.Message;
|
|
}
|
|
finally
|
|
{
|
|
IsBusy = false;
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// One case earns a translation rather than the exception's own words: pointing an HTTPS client at a
|
|
/// plaintext port reports "The SSL connection could not be established", which sends people looking
|
|
/// for a certificate problem. The scheme is the mistake, and the development stack serves HTTP, so
|
|
/// this is the first thing a new user will hit.
|
|
/// </remarks>
|
|
private static string ExplainSignInFailure(Exception exception, Uri server)
|
|
{
|
|
var secureChannelFailed = exception is HttpRequestException
|
|
&& exception.GetBaseException() is AuthenticationException;
|
|
|
|
if (secureChannelFailed && server.Scheme is "https")
|
|
{
|
|
var plain = new UriBuilder(server) { Scheme = "http" }.Uri;
|
|
|
|
return $"{exception.Message} {server.Host} answered, but not with TLS. If this is a "
|
|
+ $"development server it probably serves plain HTTP — try {plain.GetLeftPart(UriPartial.Authority)}.";
|
|
}
|
|
|
|
return exception.Message;
|
|
}
|
|
|
|
/// <remarks>
|
|
/// One place for the subscription, so unlocking, locking and disposing all route through it rather
|
|
/// than each remembering to detach.
|
|
/// </remarks>
|
|
partial void OnVaultChanged(VaultViewModel? oldValue, VaultViewModel? newValue)
|
|
{
|
|
if (oldValue is not null)
|
|
{
|
|
oldValue.SessionOpened -= OnVaultSessionOpened;
|
|
}
|
|
|
|
if (newValue is not null)
|
|
{
|
|
newValue.SessionOpened += OnVaultSessionOpened;
|
|
}
|
|
}
|
|
|
|
private void OnVaultSessionOpened(object? sender, EventArgs e) =>
|
|
TerminalSessionOpened?.Invoke(this, e);
|
|
|
|
partial void OnLiveSessionCountChanged(int value)
|
|
{
|
|
OnPropertyChanged(nameof(HasLiveSessions));
|
|
OnPropertyChanged(nameof(LiveSessionSummary));
|
|
}
|
|
|
|
partial void OnStateChanged(ShellState value)
|
|
{
|
|
OnPropertyChanged(nameof(IsStarting));
|
|
OnPropertyChanged(nameof(IsNeedingServer));
|
|
OnPropertyChanged(nameof(IsNeedingEnrollment));
|
|
OnPropertyChanged(nameof(IsShowingRecoveryCode));
|
|
OnPropertyChanged(nameof(IsLocked));
|
|
OnPropertyChanged(nameof(IsUnlocked));
|
|
}
|
|
}
|