Public Access
M2's file transfer, built bottom-up: an SFTP session on the SSH layer, a transfer queue in a project of its own, and the two-pane browser the design asked for replacing the screen that said it did not exist. Remote listings carry names, sizes, modification times and a real drwxr-xr-x — nothing in this repository could render a POSIX mode before — and the queue moves one file at a time with progress, throughput and resume. The design import assumed this would be an SFTP subsystem channel on ISshConnection, beside the shell on a transport that is already up. SSH.NET does not offer that: SftpClient derives from BaseClient and owns its own transport, and there is no supported way to hand it an SshClient's session. So file transfer opens a second authenticated connection, and it is named for that rather than dressed up as a channel — OpenSftpAsync is on ISftpSessionFactory, not on a connection. The difference is visible to a user: the host records a second login, and a host whose password is typed each time asks for it again on this screen. It goes through the same host key gate, the same pin and the same two refusals a shell does, so a fingerprint approved for a terminal is approved here and one approved here reaches the other machines with the next sync. docs/design-import-gaps.md is corrected, and marked as the one row where what shipped differs from what it predicted. Nothing is written at its final name until it is complete. Every transfer goes to a .dodossh-part file beside its destination and is renamed into place at the end, so an interrupted transfer can never be mistaken for a finished one — which matters most for what this screen is actually for, which is copying a build artefact onto a server and then running it. A destination that already exists is refused outright rather than overwritten: the queue has no way to ask, and silently replacing a file somebody's process is serving is the worse of the two failures. The remote pane has DELETE and MKDIR so that refusal is not a dead end. A test against the container pins the assumption underneath all of this — that SFTP's rename does not clobber. Resume works within a run of the application and not across a restart, and the limit is deliberate rather than unfinished. Nothing records which source wrote a part file, and resuming one on the strength of its name matching is how a corrupt artefact gets delivered with nothing reporting a failure; a part file found at startup is started over. Making it survive a restart needs the preferences store this client still has not got. The offset a resume starts at is the part file's own length rather than the transfer's recorded progress: a cancellation can land between a write completing and the counter moving, and only one of those two is a fact about the bytes that are there. The queue and its connection outlive a lock, as shells do. LockAsync already argues that locking must not destroy work in flight — it is what somebody does when they walk away from the machine, which is exactly when a long transfer is most likely to be running — so TransfersViewModel is created once and the vault is attached on unlock and detached on lock. What locking takes is the host list, and it has to: those rows carry decrypted secrets. DodoSSH.Client.Transfer is a new project rather than more of Client.Ssh. The two answer different questions — one is about reaching a host, the other about moving bytes and what to do when moving them stops halfway — and this is the only client project that deliberately touches the local filesystem. Three defects the tests found, none of which review would have. SftpPath.Name answered an empty string for the root. NavigateRemoteAsync wrapped itself in the busy guard, so navigating from inside another command did nothing at all and the remote pane simply stayed empty after connecting, with no failure anywhere to explain it. And opening an SFTP session per test made two handshakes per test — this client learns a host key by being refused — which pushed the SSH assembly past sshd's MaxStartups and failed a different few unrelated tests each run; the session is shared through the fixture now, with the reason written where the next person will hit it. 1004 tests green across 18 projects, 24 of them new: the SFTP subsystem against the OpenSSH container, the queue against a real temporary directory and a fake host, and three more layout measurements because a screen this window has never laid out is a screen never checked. Not verified: the screen has not been looked at running. The layout harness measures it at the window's minimum in three shapes, which is the class of defect that has shipped here before, but reaching it in the application needs the compose stack, the migrations, the API and a browser sign-in. What is still absent — the status bar's transfer count, dragging between the panes, transferring a directory, and sftp over a bastion — is in docs/design-import-gaps.md.
277 lines
16 KiB
Markdown
277 lines
16 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).
|
|
|
|
The desktop client's interface was built from a design covering more product than exists yet — file
|
|
transfer, teams, saved snippets, port forwarding. Everything that design asked for and this build has not
|
|
got is written down in [`docs/design-import-gaps.md`](docs/design-import-gaps.md), with the layer each
|
|
piece would land in and what the interface shows in its place. Nothing was rendered with invented data to
|
|
fill a screen.
|
|
|
|
## 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, SFTP, host key trust
|
|
DodoSSH.Client.Terminal the loopback data plane and credit-based flow control
|
|
DodoSSH.Client.Transfer the transfer queue, part files and resume, and the local file listing
|
|
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
|
|
docs/design-import-gaps.md what the client's design asked for and this build has not got
|
|
```
|
|
|
|
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.
|
|
|
|
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.
|
|
|
|
### Moving files
|
|
|
|
**FILES** in the nav rail is a two-pane browser: this machine on the left, the host on the right, and a
|
|
queue underneath. Choose a host, press **CONNECT**, then select a file in either pane and press the arrow
|
|
pointing the way you want it to go.
|
|
|
|
Two things about it are worth expecting rather than discovering.
|
|
|
|
**It is a second connection, not a second channel.** SSH itself would allow the SFTP subsystem to open
|
|
beside a shell on the transport that is already up; SSH.NET does not offer that — its `SftpClient` owns its
|
|
own transport — so pressing CONNECT here authenticates again. The host records a second login, and a host
|
|
whose password you type each time will ask for it again on this screen. Host key trust is shared: a
|
|
fingerprint approved for a terminal is approved here, and one approved here reaches your other machines with
|
|
the next sync.
|
|
|
|
**Nothing is written at its final name until it is complete.** Every transfer goes to a `.dodossh-part` file
|
|
beside its destination and is renamed into place at the end, so an interrupted transfer can never be
|
|
mistaken for a finished one — which matters most for what people actually use this for, which is copying a
|
|
build artefact onto a server and then running it. A destination that already exists is refused outright
|
|
rather than overwritten; the remote pane has **DELETE** and **MKDIR** so that refusal is not a dead end.
|
|
**RESUME** on a stopped transfer carries on from what the part file already holds.
|
|
|
|
Resume works within a run of the application and not across a restart, and that limit is deliberate: nothing
|
|
records which source wrote a part file, and resuming one on the strength of its name matching is how a
|
|
corrupt artefact gets delivered with nothing reporting a failure. A part file found at startup is started
|
|
over.
|
|
|
|
What is not here: transferring a directory, dragging between the panes, and routing a transfer through a
|
|
bastion — the last needs jump hosts the connection layer has not got. All three are in
|
|
[`docs/design-import-gaps.md`](docs/design-import-gaps.md).
|
|
|
|
### 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 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`](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 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](#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. *File transfer done:* an SFTP session, a two-pane file
|
|
browser with a real remote listing — names, sizes, modification times and `drwxr-xr-x` permission bits —
|
|
and a queue that moves one file at a time with progress, throughput and resume. See
|
|
[Moving files](#moving-files) for the two things about it worth knowing before you use it, both of which
|
|
are consequences rather than choices.
|
|
- **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).
|