jaap-jan 5a899afd78 Decide what Lock does to a running shell, and say it
Pressing Lock nulled and disposed the vault view model and touched nothing else.
TerminalWorkspace is injected from App.axaml.cs and outlives every lock, so the SSH
connection, the pty and the pump all kept running while the window said "Unlock your
vault" — and since 0500e43 collapsed the WebView while locked, that live session was
invisible as well as unstopped. CloseSessionAsync was reachable in production only from
DisposeAsync, i.e. shutdown. None of this was written down anywhere, so it was neither a
policy nor a bug, which is the actual problem.

Shells now deliberately outlive the lock, and every layer says so.

The reason to prefer this over making Lock a disconnect: locking is what a person does
when they walk away from the machine, which is exactly when a long upgrade, build or
transfer is most likely to be in flight. Ending every shell would make Lock a button that
destroys work, and the predictable response is to stop pressing it and leave the vault
open instead. The idle auto-lock this will grow decides it outright — an unattended
timeout that killed a running job would be worse than the exposure it removes. Closing
the channel also buys less than it looks: the session was authorised at connect time by a
credential the remote verified itself, and no vault key participates in keeping it alive,
so locking cannot retroactively un-authorise it any more than removing a member can.

Stated honestly rather than implied, because the lock screen is what hides it:

- The unlock screen shows how many shells are still connected, and that locking closes
  the vault and not the connections — so a machine still holding authenticated SSH
  channels does not present itself as merely "locked". Shown only when there is something
  to disclose. Quitting is what ends them, and the text admits that.
- The Lock button carries the same thing in a tooltip, since its name implies the
  opposite of what it does to a shell.
- README lists it as a third architecture consequence beside non-retroactive revocation,
  which is the same shape of honest limit; docs/crypto.md §10 records it as a threat-model
  boundary; TerminalWorkspace and LockAsync carry the argument next to the code.

LiveSessionCount deliberately does not count dictionary entries. Nothing removes a
session when the remote closes the channel by itself — RunSessionAsync only drops the
renderer registration — so sessions.Count would report a shell that exited half an hour
ago as still running, on the one screen where a user is deciding whether it is safe to
walk away. A completed Run task is what "the shell is gone" actually looks like. While
locked the number can only fall, since opening a session needs the vault, so a stale
value over-reports rather than under-reports.

Both new tests fail when the policy is reverted: the count test times out against
sessions.Count, and the shell test reports "workspace.LiveSessionCount should be 1 but was
0" when Lock closes sessions. ShellFlowTests also stops building its workspace with a real
SshNetConnectionFactory that nothing ever called, which had made the suite's independence
from the network a coincidence rather than a property.

Verified by hand with a live shell, which nothing had done: a harness mirroring
MainWindow.axaml's 340,* grid with a real NativeWebView, the shipped WebAssets, a real
sshd in a container, and an ISshShellSession decorator recording every window-change the
remote is actually told about. Across lock and unlock, no window-change reached the remote
at all, stty size answered 50 118 before and after, the renderer's own buffer came back
byte for byte with the wrapped line intact, and the session stayed live throughout. A
control run that never hides the WebView behaves identically, so nothing above is startup
or idle behaviour. Keystrokes injected while locked reach nothing: twelve of twelve
SendInput events accepted with the harness confirmed as the foreground window, no probe
character in the remote's output, and a following Ctrl-U answered BEL, so nothing was
queued in the line editor either. A hidden WS_CHILD window is not eligible for keyboard
focus, which is what makes surviving the lock defensible rather than merely convenient.

Correction to a claim made in f80b3d4: terminal.js's guard comment listed "a host that
hides the WebView while the vault is locked" among the paths that reach a degenerate fit.
It does not. Collapsing the control hides a native child window without resizing it, so
the page still reports paneWidth 840 and paneHeight 760 with unchanged cols and rows, no
ResizeObserver callback fires and the fit never runs. Establishing that rather than
assuming it: the same cycle with MINIMUM_FITTABLE_PIXELS patched to 0 — the guard fully
disabled — is equally clean. The guard is still right for minimising and for a splitter
dragged to the edge; it is simply not what makes locking safe, and must not be cited as
though it were.

Recorded, not fixed:

- Nothing closes one terminal from the interface, so a user reading "1 shell is still
  connected" can only act on it by quitting. CloseSessionAsync is tested and correct;
  VaultViewModel discards the session id it would need.
- A session whose remote exits keeps its ISshConnection, and the thread ShellStream parks,
  until the process ends.
- Suspected and seen once: before the harness waited for the window's scale to settle, a
  DPI settle pushed a 2202x1328 pane for a window 1180 logical units wide and a later
  re-push reflowed the wrapped line. Three later runs at RenderScaling 1.00 never showed
  it, so it is filed as a lead, not a finding.
- WebView2 fails to initialise with CO_E_SERVER_EXEC_FAILURE when the host executable
  sits under a very long path. Cost an hour on the harness; relevant to packaging.
2026-07-29 14:44:02 +02:00

DodoSSH

A self-hosted, team-oriented SSH client with an end-to-end encrypted vault.

Manage hosts, credentials and keys in a desktop app; sync them across your devices and share them with teammates through a server you run yourself. The server stores ciphertext and never holds a key — the operator cannot read the credentials it stores.

Status: early development. See the milestone plan for what exists today.

Why

Teams either scatter SSH credentials across individual ~/.ssh directories with no sharing story, or pay per-seat for a hosted product that holds their infrastructure credentials. DodoSSH keeps the convenience of a synced, shareable vault while remaining self-hostable and zero-knowledge.

Architecture

Component Choice
Backend ASP.NET Core on .NET 10, PostgreSQL + EF Core
Client Avalonia (C#) for Windows/Linux/macOS; terminal pane is a WebView running xterm.js
Auth OIDC, provider-agnostic (Entra ID, Keycloak, Auth0, Authentik)
Vault End-to-end encrypted; X25519 + Ed25519 + XChaCha20-Poly1305, Argon2id unlock
Connections Client-direct SSH by default, with an optional raw-TCP server relay

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/, starting with the E2EE trust model.

Repository layout

src/
  DodoSSH.Contracts        DTOs shared with the client — the real API contract
  DodoSSH.Crypto           DSH1 envelope, AAD derivation, the key hierarchy
  DodoSSH.Domain           entities and invariants, no EF
  DodoSSH.Infrastructure   DbContext, configurations, migrations
  DodoSSH.Api              the server
  DodoSSH.Client.Auth      OIDC code+PKCE on a loopback redirect, and the key binding
  DodoSSH.Client.Api       the typed server client, and client-side enrollment
  DodoSSH.Client.Domain    the decrypted item model and the three-way merge — no I/O at all
  DodoSSH.Client.Storage   the local cache: ciphertext mirror, outbox, offline unlock material
  DodoSSH.Client.Sync      the pull/apply/push loop and the conflict policy
  DodoSSH.Client.Session   where a profile lives, unlocking it, and getting one in the first place
  DodoSSH.Client.Ssh       connections, PTY shells, host key trust
  DodoSSH.Client.Terminal  the loopback data plane and credit-based flow control
  DodoSSH.Client.App       Avalonia; the only project that knows about a UI toolkit
tests/                     one test project per source project
docs/adr/                  architecture decision records

Everything under src/DodoSSH.Client.* except App is deliberately free of Avalonia. That is the seam that lets the SSH layer, the terminal's flow control and the OIDC flow be tested without a UI toolkit or a browser engine — which is most of why they are testable at all.

Building

Requires the .NET SDK pinned in global.json (10.0.x).

dotnet build DodoSSH.slnx
dotnet test DodoSSH.slnx

The tests need a Docker daemon. Everything that touches the database, the identity provider or an SSH server uses Testcontainers rather than a stub or a shared instance, so there is nothing to start first and nothing to clean up after — but with no daemon those suites fail rather than skip.

Running it

Four commands, in order. The first two are once per machine.

1. The development dependencies — PostgreSQL and Keycloak, with the dodossh realm imported:

docker compose -f deploy/docker-compose.dev.yml up -d

2. The schema. The API never migrates anything: it fails readiness while a migration is pending, and says which one. dotnet-ef is pinned in .config/dotnet-tools.json, so run dotnet tool restore first if you have not:

dotnet ef database update --project src/DodoSSH.Infrastructure

With nothing else configured this targets the compose stack above. Set DODOSSH_DESIGN_CONNECTION to point it at another database.

3. The server:

dotnet run --project src/DodoSSH.Api

It listens on http://localhost:5233, serving /healthz/live, /healthz/ready and — in Development — /openapi/v1.json.

4. The desktop client:

dotnet run --project src/DodoSSH.Client.App

In the app, enter http://localhost:5233 as the server. Your browser opens for sign-in — the realm ships alice / alice — then choose a vault passphrase and write down the recovery code, which cannot be skipped and cannot be recovered from the server. You can then add a host and open a shell on it. Keycloak's admin console is at http://localhost:18080 (admin / admin).

Three of M1's known gaps are visible immediately, so they are worth expecting rather than diagnosing: a connection asks for the host's password every time, because credentials are not a synced entity type yet; host key trust lasts one session, because known hosts do not live in the vault yet; and unlock asks for the passphrase on every launch, because no device key is registered.

End-to-end verification

One suite runs against a real server rather than a stub. It needs a Docker daemon and nothing else, so it is part of the ordinary test run:

dotnet test tests/DodoSSH.SystemTests

It brings up PostgreSQL, Keycloak and an OpenSSH server in containers, applies the committed migrations, starts the API as a child process out of its own build output, and then drives the real client: sign in through Keycloak, enroll, unlock, create a host, sync it, read it back on a second simulated machine, unlock again with no network, and open a shell on the sshd. Roughly 25 seconds once the images are pulled.

What makes it worth its weight is that it consumes the artefacts that ship — the realm file from deploy/keycloak, the EF migrations, the API's own appsettings — rather than a fixture written to match them. On its first run it found a loopback redirect URI the realm registered in a form Keycloak rejects, and a JSON configuration gap that made the whole sync surface unreachable from the real client while every other test passed. Both are the same class of bug: two sides of a stub agreeing with each other about something the specification never said.

The one value it cannot take from a committed file is Oidc:Authority, since the container's port is assigned at start. Everything that authority points at is still the real realm.

Development and testing are currently Windows-only. Anything known or suspected to differ on Linux and macOS is tracked in docs/platform-flags.md, along with the deployment gotchas that have already cost time once. Read it before assuming something works off-Windows.

Conventions the build enforces

  • Warnings are errors. dotnet format --verify-no-changes gates CI.
  • Package versions are centralised in Directory.Packages.props; packages.lock.json is committed and CI restores in locked mode.
  • BannedSymbols.txt bans DateTime.UtcNow (use TimeProvider), Guid.NewGuid (use CreateVersion7), sync-over-async, MD5/SHA1 and PBKDF2.
  • Public members of DodoSSH.Contracts must be declared in PublicAPI.Unshipped.txt, so a contract change is a build error rather than a client-side surprise.

Milestones

  • M0 — foundation. Repo structure, build conventions, CI, ADRs. Done.

  • M1 — vertical slice. OIDC login → enroll → create a host → open a shell. Server done: the DSH1 crypto core, the data model, sync push/pull for hosts, /me, and enrollment with the identity-provider key binding. Client done: the key hierarchy, the OIDC flow with the key binding, SSH connections with host key trust, the terminal data plane, the encrypted local cache with the sync client — offline unlock, an outbox and a field-level three-way merge, conflict matrix green — and an Avalonia shell that is vault-backed: server URL → browser sign-in → enroll → unlock → host list → terminal. The shell's state machine is covered by tests against an in-memory server, so the states that matter most (the recovery code that cannot be skipped, the unlock that needs no network) are checked rather than remembered.

    Its layout is not covered by anything, and that gap has already cost a shipped defect: the setup and unlock screens were layered over the terminal's WebView, which on Windows is a native child window that cannot be covered, so they rendered sliced with their buttons unclickable. No test in this repository loads a .axaml file, and a headless one could not have caught this — there is no native window in headless, so it would have rendered perfectly and confirmed the wrong belief. Screens get looked at, or they are unverified. Verified end to end: tests/DodoSSH.SystemTests drives the whole slice against a real Keycloak, a real API, a real PostgreSQL and a real sshd — sign-in, the identity-provider key binding, enrollment, offline unlock, a host through the vault to a second machine, and an interactive shell. See End-to-end verification.

    Known gaps in the client, stated rather than implied by the interface: credentials are not a synced entity type yet, so a connection still asks for a password; known host keys live in memory for one session instead of in the vault; and no device key is registered, so the passphrase is needed on every launch until the OS keystore is wired.

  • M2 — full personal vault, robust sync, relay.

  • M3 — teams, sharing, ACLs.

  • M4 — hardening and ops, packaging, self-hosting guide.

  • M5 — multi-provider OIDC, key rotation, per-item content keys.

Licence

MIT.

S
Description
No description provided
Readme MIT
8.6 MiB
Languages
C# 98.1%
PowerShell 0.7%
Shell 0.5%
JavaScript 0.4%
Dockerfile 0.2%