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)); } }