diff --git a/README.md b/README.md index 84f8108..fa09f1b 100644 --- a/README.md +++ b/README.md @@ -25,13 +25,21 @@ zero-knowledge. | Vault | End-to-end encrypted; X25519 + Ed25519 + XChaCha20-Poly1305, Argon2id unlock | | Connections | Client-direct SSH by default, with an optional raw-TCP server relay | -Two consequences worth knowing before you read further: +Three consequences worth knowing before you read further: - **Revocation is not retroactive.** A removed member keeps what they already downloaded. The real remediation is rotating the SSH credential, so offboarding is built around a rotation checklist rather than a button that implies more than it delivers. - **No session recording in relay mode.** The relay forwards SSH ciphertext, so it cannot see commands. That is the cost of the relay not being able to read your traffic. +- **Locking the vault does not close your shells.** Lock closes the vault and zeroes every key it + held; a session that authenticated before it keeps running, because the remote host never + consulted the vault and the credential was already spent. That is deliberate — you lock when you + walk away from the machine, which is exactly when a long upgrade or transfer is most likely to be + in flight, and an idle auto-lock that killed it would be worse than the exposure it removed. The + honest reading is that *locked* describes the vault and not this machine's access to your hosts. + The unlock screen therefore shows how many shells are still connected, and quitting DodoSSH is + what ends them. The reasoning behind each major decision is recorded in [`docs/adr/`](docs/adr/), starting with [the E2EE trust model](docs/adr/0001-e2ee-trust-model.md). diff --git a/docs/crypto.md b/docs/crypto.md index e5af0da..b70d454 100644 --- a/docs/crypto.md +++ b/docs/crypto.md @@ -499,5 +499,13 @@ server, its operators, its backups and the network. It does **not** address: - metadata — item counts, sizes, timestamps, access patterns and the sharing graph are visible, as are host addresses for relay-enabled hosts; - a weak passphrase — §2 parameters and passphrase entropy are the whole defence; +- **a locked vault on a machine with open sessions** — locking zeroes the identity keys, the vault + keys and the cache key, so nothing on disk can be read again without the passphrase. It does not + touch an SSH channel that is already open: that channel was authorised at connect time by a + credential the remote host verified itself, and no vault key participates in keeping it alive. + Sessions therefore survive lock **by design** (the client says so on its unlock screen, and the + README explains why), which means a locked client can still hold authenticated access to remote + hosts. Ending that is quitting the client, or rotating the credential — the same non-retroactive + limit as revocation, one layer down; - supply chain — a server can serve a backdoored client. Sign releases with a key the server does not hold. In a self-hosted E2EE product this is the largest practical hole. diff --git a/docs/platform-flags.md b/docs/platform-flags.md index 2921b9f..e9dd98c 100644 --- a/docs/platform-flags.md +++ b/docs/platform-flags.md @@ -158,6 +158,53 @@ headless test renders and focuses correctly and would confirm the wrong belief. the plumbing that drives it — that connecting asks for focus once per session, that a failed connect does not, and that locking stops the forwarding. +**The lock/unlock cycle does not resize the pane at all, and the 40 px guard is not what makes it safe.** +Measured on Windows with a live shell, against a real `sshd` in a container, in a harness mirroring +`MainWindow.axaml`'s `340,*` grid: with the `NativeWebView` collapsed by `IsVisible=false`, the page still +reports `paneWidth: 840, paneHeight: 760`, unchanged `cols`/`rows`, and `visibilityState: "visible"`. +Hiding is `SetWindowPos(holder, …, SWP_HIDEWINDOW)`, which does not resize the holder, so no +`ResizeObserver` callback fires, no fit runs, and **no `window-change` reaches the remote** — before, +during or after the cycle. `stty size` on the remote answered `50 118` both before locking and after +unlocking, and the renderer's own buffer came back byte for byte, wrapped lines included. + +The guard's irrelevance here was established rather than assumed: the same run with +`MINIMUM_FITTABLE_PIXELS` patched to `0` — the guard fully disabled — produced an identical clean result. +So the guard is still worth keeping for the paths it was written for, minimising and a splitter dragged to +the edge, but it is **not** on the lock path and must not be cited as the reason locking is safe. It was +described that way when it landed. + +Two further results from the same harness, both about the deliberate decision that shells outlive a lock +(README, `MainWindowViewModel.LockAsync`): + +- **A collapsed WebView is not typed into.** With the harness confirmed as the foreground window and all + twelve injected `SendInput` events accepted, not one character of the probe reached the remote pty, and a + `Ctrl-U` afterwards answered `BEL` — nothing was sitting in the remote's line editor either. So the lock + screen is a real input barrier even though the session behind it is live, and that is what makes + surviving the lock defensible rather than merely convenient. The *mechanism* is not what this run + concluded: it read the result as a hidden `WS_CHILD` window being ineligible for keyboard focus, but the + focus entry above measured Win32 focus still held by the hidden holder window, and the lock path now + moves the keyboard off it deliberately. Take the barrier as measured here and the reason from there — + which also means the barrier is something the lock path maintains, not something the platform guarantees. +- **The session survives the cycle in the real control, not only in tests.** `LiveSessionCount` was 1 + before, during and after, and the shell accepted a command again immediately on unlock. + +*Suspected, seen once, not reproduced:* on the first run — before the harness learned to wait for the +window's scale to settle — the window opened at 2558x1367 px and the page reported a 2202x1328 pane +(312x88 characters) for a window 1180 logical units wide, which looks like physical pixels arriving where +CSS pixels were expected. A later re-push to 1177x672 then reflowed the wrapped line and split it in two. +Both events straddled a DPI settle rather than the lock, and three later runs at `RenderScaling 1.00` +never showed it. If a user reports mangled scrollback after moving the window between displays of +different scale, start here. + +**WebView2 will not initialise when the host executable sits under a very long path.** +`CreateCoreWebView2Environment` fails with `COMException 0x80080005 CO_E_SERVER_EXEC_FAILURE` ("Server +execution failed") and the terminal never appears. Hit while building the harness above: the same binary +that failed from a ~230-character directory ran first time from `%TEMP%\h`. The exact threshold was not +established and the mechanism is unconfirmed — the user data folder is created beside the executable by +default and the browser process is launched with paths derived from it, so `MAX_PATH` is the obvious +suspect. Relevant to packaging: an installer that lands under a deep per-user path would break the +terminal with an error that names nothing. + **The Windows app manifest must declare a `supportedOS` list.** Without it the process reports a downlevel Windows version and Avalonia's native control host fails outright — *"Unable to create child window for native control host"* — so the WebView, and therefore the terminal, does not start at all. diff --git a/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs index 53a19df..f1af6f4 100644 --- a/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs +++ b/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs @@ -139,6 +139,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp [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; @@ -351,7 +367,34 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp }).ConfigureAwait(true); } - /// Closes the vault and forgets every key it held. + /// + /// 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() { @@ -361,6 +404,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp await open.DisposeAsync().ConfigureAwait(true); } + LiveSessionCount = workspace.LiveSessionCount; + State = ShellState.Locked; StatusMessage = "Locked."; } @@ -500,6 +545,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp 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)); diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml b/src/DodoSSH.Client.App/Views/MainWindow.axaml index 8664cc4..2e6712e 100644 --- a/src/DodoSSH.Client.App/Views/MainWindow.axaml +++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml @@ -68,7 +68,12 @@