Outstanding items #8 and #9, in one commit rather than two. They are separable as work and were built in that order, but not as a diff: the section enum has three members, the one-editor guard has three arms, and the picker offers keys and credentials from the same list. Reconstructing an #8-only state would mean hand-writing an intermediate version of VaultViewModel that never existed and that no test has ever run. One honest commit beats two invented ones. --- #8, the type selector --- The column showed two lists and two editors stacked in 340 pixels, and only just: the key list needed a MaxHeight and had to hide itself whenever its editor opened, both to stop the host list above it pushing the buttons off the bottom edge. Credentials would not have fitted at all. It now shows one kind at a time, chosen by a selector at the top, and both workarounds are gone because a section owns the whole column. Three departures from the plan, each with a reason found while building it. The selector is plain Buttons and a parameterised command, not a TabControl, a TabStrip or a ListBox. All three of those hold the selection themselves, so a click moves the highlight before the view model can refuse it — and this column does refuse, while an editor is open. A selector lit on a section the column is not showing is worse than the refusal it would be hiding. Buttons carry no state and cannot disagree with the vault. The one-editor-at-a-time rule survives with its justification replaced. That rule was a workaround for the sizing problem above, and sections dissolved it: the editors are in different sections and only one section is ever laid out. BothEditorsAtOnce_DoNotFit_WhichIsWhyTheRuleExists is now BothEditorsOpen_NowFit_BecauseOnlyOneSectionIsLaidOut — the same test, inverted, because its own comment said that if it ever started passing the rule had become unnecessary. It has. The rule stays for a better reason: an open key editor holds a pasted private key in a bound string, and letting the column move on would leave key material in a form nobody can see, with nothing on screen to say it is there. A sizing hack became a rule about not hiding a secret from the person holding it. KeyEditorIsInTheWay and HostEditorIsInTheWay are one AnEditorIsInTheWay, called by the section switch and by every editor-opening command. And releasing the keyboard from the terminal has never worked. MainWindow takes Win32 focus off the WebView's child window and then calls Focus() on VaultColumn.KeyboardTarget — and a ListBox is not focusable by default in Avalonia, which leaves focus to its items. So the call returned false, the window ended up with nothing focused, and the keystrokes went nowhere: exactly the state that method's own comment says its second half exists to prevent. Found by writing the test to assert focus was taken rather than that the right control was named — the cheap assertion was already passing. Fixed with Focusable="True" on every list. --- #9, credentials --- Credentials have synced since they were added and could not be created. They can now, and the sync layer needed no change at all: fourth item type, same result, which is the item-kind seam working as intended. One picker for all three ways a host authenticates, which is what makes the illegal combination unrepresentable rather than merely invalid. SshKeyChoice became AuthenticationChoice carrying an AuthenticationKind, and BuildHost reads both SshKeyId and CredentialId off that single selection, so a host naming a key and a credential — which HostSecret.TryValidate refuses — cannot be expressed. Two pickers would have expressed it and then rejected it at save time. The kind travels with the id in three places and none is padding: Missing takes it, the placeholder lookup matches on kind as well as id, and Bound(kind) returns null unless the selection is that kind. Drop any one and a dangling credential comes back as a dangling key, which saves as a key binding to an id no key has. A credential's username had to reach the SSH request, not just its password. TryBuildCredential returned only the secret and the connect path read the username off the host, so a stored credential would have gone out under the wrong account — wrong in a way a server only reports as "authentication failed". It is now TryBuildAuthentication returning a (Username, Credential) pair. The no-username refusal moved, and had to. It ran before anything looked at the binding, which made a credential's username unreachable in the one case it is most useful: a host somebody never filled a username in for. It is now the last thing every branch agrees on, so such a host is perfectly usable through a credential that carries one, and a host with neither still refuses and now says where to put one. --- What the measurements cost --- Ten mutations, all caught. Two are worth naming. Removing a section's IsVisible is caught by OnlyOneSectionIsOnScreenAtOnce and by nothing else: two visible sections overlap in the row they share rather than clip, so every fit test still passes while the column shows one list through another. Defaulting the credential selection to the first row is caught by ReloadingKeepsACredentialSelectionButNeverInventsOne, and the property is a safety one rather than tidiness — Delete acts on the selection, so a list that picked a row on every background sync would aim a one-click password deletion at something nobody chose. The key list has the same property, and its comment cited a method that has not existed for some time; both now name the delete command they actually protect. One test of mine could not fail, and the mutation pass is what found it. AHostBoundToACredential_SendsItsPasswordAndItsUsername gave the credential and the host the same username, so it passed whichever one the code read. An override is only tested when the two values differ. Two shipped statements went false and were corrected rather than left: the class remark saying passwords were "not yet" in the vault, and the terminal column's "Keys are in the vault; passwords are not yet." That column's hint is now a tooltip on the password box rather than a sentence in the row, which was measured the hard way — by looking. At the window's 820px minimum the column gets 480, and a 220px box plus Connect plus any sentence does not fit; the row has shipped clipped for as long as it has had a hint in it. That strip is the one part of the window nothing can measure, because MainWindow cannot be laid out headlessly at all. Extracting it into its own control, as the vault column was extracted for exactly this reason, is what would fix that, and is not done here. 911 tests green, 30 of them new. Zero warnings, dotnet format clean. Seen by a person, which is how the two defects above were found. Still open from that pass: unlocking with the device key raises its consent dialog and then never returns, while registering one works — the difference is which thread the CNG call lands on, and diagnosing it properly is its own change.
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).
You can also add an SSH key, which is stored in the vault like a host and synced the same way: paste the private key, then edit a host and pick that key from its key dropdown. From then on that host authenticates with it — on every machine, since the choice travels inside the host's encrypted payload — and its password box disappears.
The first time you connect to a host you are asked to check its key fingerprint. That decision is stored in the vault, so it is asked once per host rather than once per launch and it reaches your other machines with the next sync. If a server is legitimately rebuilt and offers a new key, the connection is refused outright with no way to continue from the warning — edit the host and choose Forget host key, which is deliberately somewhere you have to go on purpose.
Two of M1's known gaps are visible immediately, so they are worth expecting rather than diagnosing: password authentication asks for the password every time, because nothing in the interface can create a vault credential yet (they do sync — there is just no editor for one); 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 an SSH key and a host bound to it, sync them, open a shell on the
sshd and approve its host key at the real first-contact refusal, then read all three back on a second
simulated machine and unlock again with no network. 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-changesgates CI. - Package versions are centralised in
Directory.Packages.props;packages.lock.jsonis committed and CI restores in locked mode. BannedSymbols.txtbansDateTime.UtcNow(useTimeProvider),Guid.NewGuid(useCreateVersion7), sync-over-async, MD5/SHA1 and PBKDF2.- Public members of
DodoSSH.Contractsmust be declared inPublicAPI.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
.axamlfile, 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.SystemTestsdrives the whole slice against a real Keycloak, a real API, a real PostgreSQL and a realsshd— sign-in, the identity-provider key binding, enrollment, offline unlock, a host and an SSH key through the vault to a second machine, an interactive shell, and the host key approved at that shell's prompt reaching the second machine as well. See End-to-end verification.Known gaps in the client, stated rather than implied by the interface: nothing in the interface can create a vault credential yet, so password authentication still asks for the password each time — SSH keys are editable, and binding one to a host is the way to connect without typing anything; and no device key is registered, so the passphrase is needed on every launch until the OS keystore is wired.
Host key trust is in the vault, which is what makes trust-on-first-use worth having: a fingerprint approved on one machine is approved on all of them and survives a restart, and the server cannot drop a pin to force a fresh first-use decision without the item visibly going missing. A changed host key stays a hard refusal with no way past it; withdrawing a pin is a separate, deliberate act in the host's editor.
Binding a key introduced the first payload schema version bump, and it is worth knowing how it behaves: a host is written at the lowest schema version that can represent it, so only hosts that actually bind a key are written at version 2 and become read-only on an older build. Hosts that do not are still written at version 1, byte-identically to before the field existed — which is what keeps upgrading one machine from making a team's whole vault uneditable everywhere else.
-
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.