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;
/// Which of the shell's mutually exclusive screens is showing.
internal enum ShellState
{
/// Reading the cache to find out whether this machine is enrolled.
Starting = 0,
/// Nothing is cached. The user has to name a server and sign in, which needs a network.
NeedsServer = 1,
/// Signed in, but the account has no vault key yet.
NeedsEnrollment = 2,
///
/// Showing the recovery code, and refusing to move on until the user confirms they have it.
///
///
/// 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.
///
ShowingRecoveryCode = 3,
/// Enrolled. The passphrase opens the vault, with or without a network.
Locked = 4,
/// Open.
Unlocked = 5,
}
///
/// The shell: get to an unlocked vault, then hand over to .
///
///
///
/// 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
/// /.well-known/dodossh-configuration, 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.
///
///
/// 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.
///
///
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;
///
/// Establishes a connection to a server.
///
///
/// A delegate rather than a direct call to , 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.
///
internal delegate Task 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;
///
/// The address dotnet run --project src/DodoSSH.Api actually serves, so the first launch after
/// a clone works without the user having to know a port. This was https://localhost:7217, which
/// is the API's second launch profile: the first is HTTP on 5233 and is the one both the
/// README and a plain dotnet run 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 . 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.
///
[ObservableProperty]
private string serverUrl = "http://localhost:5233";
[ObservableProperty]
private string passphrase = string.Empty;
[ObservableProperty]
private string confirmPassphrase = string.Empty;
/// Shown once, immediately after enrolling, and never stored anywhere.
[ObservableProperty]
private string? recoveryCode;
[ObservableProperty]
private bool recoveryCodeWrittenDown;
/// Who this machine is enrolled as, readable without the passphrase.
[ObservableProperty]
private string? accountName;
[ObservableProperty]
private VaultViewModel? vault;
///
/// Shells that were left running when the vault was locked.
///
///
/// Refreshed by , which is where the policy this reports is explained.
///
[ObservableProperty]
private int liveSessionCount;
internal bool HasLiveSessions => LiveSessionCount > 0;
/// The count as a sentence, because a bare number on a lock screen explains nothing.
internal string LiveSessionSummary => LiveSessionCount == 1
? "1 shell is still connected and still running."
: $"{LiveSessionCount} shells are still connected and still running.";
/// Where the embedded browser should navigate.
internal Uri TerminalPageUrl => workspace.PageUrl;
///
/// Raised when a terminal session opens, so the view can hand the terminal the keyboard.
///
///
/// Forwarded from rather than exposed there directly,
/// because 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.
///
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;
/// Whether a connection to the server is currently held.
internal bool IsOnline => connection is not null;
///
/// Brings the schema up to date and works out which screen to show.
///
///
/// 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.
///
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}";
}
}
/// Discovers the server and runs the browser sign-in.
[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);
}
/// Creates the identity key and the personal vault.
[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);
}
/// Leaves the recovery-code screen, once the user says they have it.
[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.";
}
/// Opens the vault.
[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);
}
///
/// Closes the vault and forgets every key it held. Open shells keep running.
///
///
///
/// Lock is a vault operation, and deliberately not a disconnect. 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.
///
///
/// What "locked" therefore describes. 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 is shown on the unlock screen rather than left to be
/// inferred from a terminal that the lock screen hides.
///
///
/// 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.
///
///
[RelayCommand]
private async Task LockAsync()
{
if (Vault is { } open)
{
Vault = null;
await open.DisposeAsync().ConfigureAwait(true);
}
LiveSessionCount = workspace.LiveSessionCount;
State = ShellState.Locked;
StatusMessage = "Locked.";
}
///
public async ValueTask DisposeAsync()
{
if (disposed)
{
return;
}
disposed = true;
if (Vault is { } open)
{
await open.DisposeAsync().ConfigureAwait(false);
}
connection?.Dispose();
}
///
/// 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.
///
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;
}
///
/// 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.
///
private SessionOpener Opener() => new(caches, clock, connection?.SyncOptions);
private AccountProvisioner? Provisioner() =>
connection is null
? null
: new AccountProvisioner(
connection.Account, connection.KeyBinding, caches, clock, passphraseProfile);
///
/// 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.
///
/// Shown while the work runs.
/// The work.
///
/// 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.
///
private async Task RunAsync(
string busyMessage,
Func work,
Func? 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;
}
}
///
/// 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.
///
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;
}
///
/// One place for the subscription, so unlocking, locking and disposing all route through it rather
/// than each remembering to detach.
///
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));
}
}