Files
DodoSSH/README.md
T
jaap-jan 70b3290a77 Bind an SSH key to a host instead of picking one per connection
A host now names the key it authenticates with, or none, as a field in its
encrypted payload — so the choice follows the host to every machine rather than
being made again each time somebody connects. The per-connection "Use key"
switch it replaces was a stopgap for not having this, and keeping both would
have left two mechanisms answering one question.

This is the first payload schema version bump, and it does not work the obvious
way. A host is written at the *lowest* schema version that can represent it: one
that binds a key is written at 2, one that does not is still written at 1, byte
for byte as it was before the field existed. The version is what makes an older
client refuse to edit an item, so stamping 2 unconditionally would mean
upgrading a single machine and renaming a single host made that host uneditable
on every machine that had not upgraded yet. Confining the cost to the hosts that
actually use the field is the difference between a team noticing a bump and a
team being blocked by one. HostSecretCodec states the rule so the next field
added follows it, and a test pins the version-1 bytes against a literal rather
than against the codec, because the claim is about history: every host already in
every vault has to re-encode to what it encoded before, or the first sync after
an upgrade would push the whole vault as changed.

A binding is an item id, not a copy of the key — a second copy of a private key
is one that goes stale — which means the reference can dangle when the key is
deleted on another machine. Both places that meets are handled the same way, by
refusing rather than falling back:

- Connecting to a host whose key is gone is refused outright. A host somebody
  deliberately set up for key-only access must not quietly start offering a
  password.
- Opening such a host in the editor keeps the binding, selected, labelled as
  missing. The quieter version of the same failure is someone editing the port
  and saving, silently converting the host to password authentication with
  nothing ever having said so.

Two things this found by being falsified:

- The merge was untested for the new field, and "just take the server's value"
  passed the entire suite — a local binding change would have been discarded with
  no conflict recorded. HostSecretMergeTests already had a test written for
  exactly this class of omission; it simply had not been extended.

- Adding a nullable field exposed a defect in HostSecretMerge.Field: it
  short-circuited when the discarded value was null, so the formatter never ran
  for the one case where null is a value rather than an absence, and a field
  whose absence has a name could not report it. Now the formatter always runs,
  and "no key" appears in the conflict log where an empty string used to.

Also fixes eight nullable warnings in SyncEndpointTests left by the server-side
SSH key commit, which had omitted the null-forgiving operator the rest of that
file uses. They were invisible until an unrelated change forced the project to
recompile.

The end-to-end slice now binds its host to its key, so a schema-version-2
payload goes through the real API, the real PostgreSQL and back out on a second
machine.

745 tests green. Zero warnings, dotnet format clean.
2026-07-29 20:42:51 +02:00

223 lines
12 KiB
Markdown

# 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](#milestones) 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/`](docs/adr/), starting with
[the E2EE trust model](docs/adr/0001-e2ee-trust-model.md).
## 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`](global.json) (10.0.x).
```bash
dotnet build DodoSSH.slnx
```
```bash
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:
```bash
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:
```bash
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:**
```bash
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:**
```bash
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.
Three 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 credentials are not a synced entity type
yet (keys are — passwords are not); 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:
```bash
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`](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`](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](#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 password authentication still asks for the password each time — SSH keys *are*
synced, and binding one to a host is the way to connect without typing anything; 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.
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](LICENSE).