# 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#): a desktop head for Windows/Linux/macOS and a phone-first Android head, sharing one set of view models; 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.ObjectStore S3-compatible buckets, behind the same interface as SFTP DodoSSH.Client.Import reading ~/.ssh/config, with no I/O of its own DodoSSH.Client.Shell the view models both heads drive, the renderer's files, the palette DodoSSH.Client.App the desktop head: its views, and its Windows integration DodoSSH.Client.Android the phone head: its views, and its Android integration 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 docs/platform-flags.md what differs off Windows, and the gotchas that have cost time docs/manual-checks.md what no test can reach, and what to look for when checking by hand docs/android-port.md the Android head: what was decided, what is built, what is left ``` Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` 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. `Shell` is the narrow exception and it earns it: what it takes from Avalonia is `Dispatcher`, the asset loader and a resource dictionary, none of which imply a window, and what it holds is the shell's state machine — which two heads have to agree on exactly rather than approximately. The rule's purpose was never Avalonia-avoidance for its own sake; it was that the layers with the hard logic stay testable, and none of them are here. ## 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 Three commands, in order. The first is 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 server:** ```bash dotnet run --project src/DodoSSH.Api ``` It applies any pending migrations before it opens its port, so there is no separate schema step and no window in which a request meets a half-applied schema. Set `Database:AutoMigrate` to `false` where something else owns the schema — a migrator job, or a database user denied DDL — and the old behaviour comes back: readiness fails while a migration is pending, and the log says which one. To apply them by hand, `dotnet-ef` is pinned in `.config/dotnet-tools.json` (`dotnet tool restore` first if you have not): ```bash dotnet ef database update --project src/DodoSSH.Infrastructure ``` With nothing else configured that targets the compose stack above. Set `DODOSSH_DESIGN_CONNECTION` to point it at another database. The server listens on `http://localhost:5233`, serving `/healthz/live`, `/healthz/ready` and — in Development — `/openapi/v1.json`. **3. 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 — double-click its card, or select it and press **CONNECT** in the drawer that opens beside the grid, which is the same command with the password box above 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. **Deleting asks first, and the question is worth reading.** DELETE on a host, an SSH key or a stored password puts a question where the buttons were, and what it says is counted rather than generic: how many hosts authenticate with the key about to go — they refuse to connect afterwards rather than falling back to a typed password — whether a terminal is open on the host about to go, and whether this machine can push the deletion yet or is queuing it. There is no undo, which is the other thing it says. Withdrawing host key trust is the deliberate exception: it costs one fingerprint check on the next connection, and the dangerous button there is the one that *adds* trust. **Signing in once is enough.** The refresh token is kept in the local cache, sealed under the vault's own key, so a later launch resumes the session itself and no browser opens — and because it is sealed under that key, resuming can only happen *after* the vault is unlocked. A machine that unlocks with no network keeps trying: every synchronisation pass asks for a connection, so a laptop opened on a train is online again within a minute of finding a network, with nothing pressed. Unlock takes **Enter** in the passphrase box, and nothing about unlocking ever waits on the network. **Signing out** is under Preferences → *Account*, and again on the unlock screen, where it is the only answer to a forgotten passphrase — nothing can recover one. It asks first, and says what it costs: it empties this machine's cache (the profile, the cached items, and anything still queued to be sent) and withdraws this machine's device key from the account. The vault itself is on the server and is untouched, so signing in again brings it all back; the count in the confirmation is the one thing that exists nowhere else. Your session at the identity provider is *not* ended — DodoSSH has no way to end it — so on a machine that is not yours, sign out there too. **Neither a password nor a passphrase has to be typed twice**, and both ways out of that are opt-in. A password typed to connect is typed once: tick **Remember this password** under the box and it is saved to your keychain and bound to that host the moment the remote accepts it — or add one outright with **+ PASSWORD** on the Vault screen. And unlock can be a Windows confirmation instead of the passphrase: Preferences → *This machine* → **REGISTER** keeps this machine's device key in the TPM, so a later launch offers **USE WINDOWS HELLO** on the unlock card. A machine with no TPM — and any desktop that is not Windows — is offered neither button and keeps asking for the passphrase, which Preferences says out loud rather than leaving you to notice. The passphrase never stops working either way: a declined confirmation leaves the box exactly where it was. ### Moving files **SFTP** and **S3** in the tab strip are a two-pane browser: this machine on the left, the remote on the right, and a queue underneath. The right-hand pane opens on an invitation rather than a listing — press **SELECT HOST**, choose one, press **CONNECT** — and once something is open, 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. DELETE asks first and names the full path, and it carries the strongest warning in the application on purpose: everything else DodoSSH deletes is a tombstone against a copy the server still holds, and a file on somebody's host is bytes with nothing behind them. **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). ### Working as a team **TEAMS** in the nav rail creates a team, adds and invites members, shares vaults, hands a team over and archives one. One distinction runs through the whole screen and is worth having before you use it. **Adding somebody to a team and giving them a key are two different acts, and only the first is something the server can do.** Adding a member changes what the server will *serve* them: the team's vaults appear in their list immediately. It cannot make those vaults readable, because a vault key is sealed to each member's public key and this server never holds one — so until somebody presses **SHARE KEY** from a machine that has the key, their vault sits in the list saying it is waiting for one. That is not a rough edge to be smoothed over later; it is what "the operator cannot read the credentials it stores" costs, and the screen says so rather than implying the server handed anything out. Sharing verifies before it wraps. The client reads the server's append-only key log, checks its hash chain from the first entry, and refuses unless the key the directory just offered appears in that log unchanged. That converts a key substitution by the server from invisible into visible — a substituted key has to be published in a log every other client also reads. **It does not prove the key is the right person's.** Compare the fingerprint with them over something this server does not carry; that is the only step that closes it, and the success message says so every time. **Somebody with no account here yet can be invited, and nothing is sent.** There is one button — **ADD MEMBER** — and it does whichever of the two applies, because which one applies is a fact about the server's account table rather than about what you are trying to do. If the directory knows the address, that account is added straight away. If it does not, the address is invited instead, and the status line says which happened, because the difference decides what you do next. An invitation is a standing instruction rather than a message: the next account that signs in with that address joins this team, at the role you chose. There is no link and no token, because this server has no outbound mail path and does not pretend otherwise — telling them to go and sign in is your job, over a channel this server does not carry, and a link nobody can deliver would be worse than no link. An invitation lasts fourteen days, so an address handed on to whoever takes the job next does not carry a standing offer for ever; it can be withdrawn until it is taken up; and like adding a member it grants nothing readable, so somebody still has to press SHARE KEY afterwards. The one thing the merged button costs is worth knowing. Adding an account the directory knows also hands you the public key you are about to verify and wrap a vault to, and an invitation cannot do that because there may be no key yet. So when you are adding somebody *in order to* share a vault with them, the useful sequence is still the same one: add them, see them appear in the members list, then share. Inviting an address that already belongs to a member of the team is refused and says so. Inviting one that merely *has* an account here is not — that would make this a way of asking the server which addresses have accounts, which is not a question anybody willing to create a team first should be able to put to it. Such an invitation simply gets claimed sooner: within the hour, on the same sweep that records they were here, rather than waiting for a first sign-in that has already happened. **An invitation is only claimed if your identity provider says the address is verified, and there is no way to relax that.** The access token has to carry `email_verified` as true. Anything else — false, missing, or sent under another name — claims nothing at all, and no setting turns that off: an invitation decides what the server will serve, and one that could be taken by anybody able to obtain a token asserting somebody else's address is a way into a team. **If your invitations never activate, this is the first thing to check.** They sit at *pending* rather than failing, the server logs a warning each time it declines to claim one, and the two fixes are on your side: set `Oidc:EmailVerifiedClaim` to whatever your provider calls the claim if it is not `email_verified`, and make sure the provider puts it in the **access** token rather than only in the ID token or the userinfo response. **Ownership is sole, and handing it over is one act.** Transferring names an existing active member: they become owner and you become an admin, in a single transaction. Not two role changes — promoting first leaves the team owned twice, demoting first leaves it owned by nobody, and there is nobody with the authority to finish a transfer that stopped in the middle. You are demoted rather than removed, so you keep your vault key grants; removing you would revoke them and rotate every team vault, and somebody handing over a team is usually staying in it. **Archiving a team is refused while it owns a vault, and that is a limit rather than a rough edge.** A team vault is readable *because* of membership, so archiving a team that still owned vaults would take them away from everybody holding a key — including you — quietly and all at once. Nothing in this product deletes a vault, so there is no order of operations that gets past the refusal today, and it says so with a count of what is in the way rather than failing vaguely. Archiving an empty team takes its memberships and its outstanding invitations with it, in one transaction. Its name can be changed whenever you like; its slug cannot, because a slug is unique only among live teams and a rename could take one an archived team is still holding. Four limits, stated rather than discovered: - **Removing a member is not retroactive.** It revokes their grants, rotates every team vault your machine can open, and hands each new key to the members who are left — so nothing written from that point on is readable to them. Everything they already pulled is still on their machine. Rotate the SSH credentials that matter — that is the actual remediation, and it is why there is no button labelled anything stronger. - **A rotation moves the vault's contents too, and says so when it could not.** The key changes first, in one server transaction; then every item already stored is re-sealed under it, so the key somebody left with opens nothing that is still here. Existing items keep working throughout — everybody still in the team holds the older keys as well as the new one, which is what stops a half-finished rotation making a vault unreadable, and what makes the pass safe to interrupt and run again. An item somebody else was editing at that moment is left for the next pass, and the message tells you which of the two you got. See [ADR 0010](docs/adr/0010-vault-key-rotation.md). - **Adding a member shares the vaults you can open, including their history.** Membership is still one act and a key is still another — nothing changed about that — but the client now performs the second one for you, wrapping every generation it holds so the new member can read the vault back to its first item. A vault your machine holds no key to is skipped and says so; somebody who holds it has to share that one. - **Host key trust stays in your personal vault.** A pin approved for a team's host is recorded and used from your own vault, not the team's, so a teammate cannot pre-approve a fingerprint that your client will then trust silently for a host you defined. The cost is that each member approves a team host's key once on each of their machines. Team vaults' pins are still *listed* on the Vault screen, so you can see what has been trusted. - **LAST ACTIVE is coarse on purpose.** The server records it at most once per account per hour, so a value an hour old means "recently" and not "at that moment". That is the granularity the question is really asked at — whether somebody is still using this deployment — and writing it on every request would put an UPDATE on the hot path of every authenticated call for a number nobody reads that closely. It is shown as roughly-when rather than to the minute, because showing it to the minute would be reading a precision into it that is not there. Items are filed into one vault at a time. When more than one vault is writable, the host and vault editors show a picker; it defaults to your personal vault and never moves on its own, because an item put in a team vault is visible to everybody in that team and moving it back means deleting and retyping. ### The Android head `src/DodoSSH.Client.Android` is a phone-first head that shares every view model with the desktop one — the keychain and a terminal, which is the scope [`docs/android-port.md`](docs/android-port.md) decided on and the reasoning behind it. Sign in, unlock, browse hosts, open a shell, and read the keychain; the two host-key decisions and the counted delete confirmations are there too, and none of them were softened to fit 360dp. Its interface is the **v2 design**: destinations in a bottom bar, with the rest one tap deeper behind the last. The bar is three — **Hosts**, **Connections** and **Settings** — with the keychain, snippets, SFTP, S3 buckets, logs, teams and preferences behind Settings. A bottom bar is for the places a session moves between, and managing keys is not one of those. Both heads are on that design now; the desktop's own v2 is a 190-pixel labelled nav rail in place of the icon rail, a centred search box in the titlebar, and session tabs as pills, and it keeps its Keychain entry — its rail has the room. Its light theme is not built — see [`docs/design-import-gaps.md`](docs/design-import-gaps.md) — so the application is dark on both. **Connections is where a connection is made, not only where one is shown.** With nothing open it offers a box taking `user@host` or `user@host:port` and a password, and lists the machines most recently connected to underneath. That box is the one path in the product to a machine the keychain has never heard of — the case somebody has just been handed an address — and nothing typed into it is saved: a machine worth keeping belongs on Hosts, where it can carry a key, a group's defaults and a name. Tapping a recent machine goes to its host if it has one and back into the box if it does not. **A connected phone shows one bar and then the terminal.** The header, the session strip and the bottom bar are collapsed while a shell is up, and a single 35-pixel row replaces them: back on the left, the sessions as pills, and a `+` on the right offering the three connections this application can make — a shell, a host's files over SFTP, or a bucket. The system back gesture does what the arrow does, and lowers that menu first if it is open. Widening the rail moved the desktop window's minimum from `880x560` to `1016x574`, which leaves every screen exactly the width it was designed against. A third desktop pass has since moved the furniture. The tab strip belongs to the window rather than to the terminal: **Vaults**, **SFTP** and **S3** are fixed tabs at its head and open terminals follow them, which took SFTP and S3 out of the nav rail — they are the two destinations you stay in while something runs. The hosts screen became a grid of cards, groups above and hosts below, with a right-hand drawer for whichever host is selected and for both editors; the 268-pixel host sidebar is gone. Text is white rather than the design's blue-tinted `#E3E7F4`, and the type scale is a point larger. File transfer **is** here now, in the shape scoped storage allows: one remote pane and the queue, over either an SFTP host or a bucket. There is no local pane, because there is no browsable local filesystem to put in one. So the way in is **ADD FILES**, which is the system document picker: point at a document wherever it lives and it goes to the directory showing, rather than choosing on the left and pressing an arrow. What Android hands back is a `content://` URI with no path behind it and no promise of a seek, so the document is copied into the app's own cache and the copy is what the queue moves — which is what lets a stopped upload resume from where it stopped. The copy is deleted when the transfer finishes, kept while it is stopped so RESUME has something to read, and swept at the next launch. **SAVE FILE is the way back out**, and it is the system's save picker for the same reason: there is nowhere this application could put a file that you would then be able to open. You choose where it goes before the transfer starts, the download runs into the cache, and the finished bytes are copied out to the document you chose. That order has one visible cost, and the screen says it rather than leaving it to be discovered: the picker creates the file when you dismiss it, so a download that then fails leaves an empty one there. The alternative is a picker that appears minutes later over whatever you moved on to — and often while the app is in the background, where Android will not show one at all. Hosts and groups are made and corrected here now, from a floating + on the Hosts screen, and both editors are cards in the list's own row rather than dialogs, so the form never covers the thing it is about. The keychain has no editor of its own: SSH keys and buckets are created on the desktop and sync down, and the phone will delete an item — behind the same counted confirmation — without offering to change it. What this head does make, it makes where the need arises rather than in an editor: a tag from inside a host's editor, and a credential from the connect bar's remember tick, which stores the password just typed and moves the host onto it. Renaming either is still a desktop job. Pins and import have no phone screen either, and importing an `~/.ssh/config` has no meaning on a phone at all. **TEAMS does have one**, behind MORE, and it is there for a reason the design could not have anticipated: an invitation is claimed by signing in, so somebody being told they have been put in a team is at least as likely to be holding a phone as sitting at a desktop, and a membership visible only on a head they have not installed is a membership they cannot see. **Port forwarding is not built anywhere**, and the phone's More screen says so in a paragraph rather than leaving a gap. The v2 design draws a whole screen for it; nothing in the SSH layer forwards anything, so every control on that screen would have had no effect. See [`docs/design-import-gaps.md`](docs/design-import-gaps.md). It is deliberately **not** in `DodoSSH.slnx`. Putting it there would make the `android` workload and a full Android SDK a prerequisite of `dotnet build DodoSSH.slnx` for everybody; it has its own CI job instead, which builds *and packages* it, because the two failures it is most exposed to — a native library with no Android ABI, and an assembly that resolves but will not dex — are both invisible to a plain compile. Building it needs the workload and **API 36 specifically**: ```bash dotnet workload install android ``` ```bash dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj ``` API 36 is not a preference. `Avalonia.Controls.WebView` ships only a `net10.0-android36.0` assembly, so anything lower cannot resolve it and the head loses its terminal. The floor is API 28, which is where `BiometricPrompt` and StrongBox-backed keys exist without an AndroidX shim. Four things about it are worth expecting rather than discovering. **Signing in does not use the desktop's loopback redirect, and must not.** On a shared device any other application can bind a loopback port and race for the authorization code — the attack RFC 8252 §8.3 names. The phone registers a redirect with the system instead and is handed the response as an intent. Everything above that — PKCE, the state check, discovery, the token exchange, the key binding — is the same code the desktop runs, because the only thing that varies is where the response arrives. The redirect is a private-use scheme (`dev.dodotech.dodossh:`) rather than an Android App Link, and the limit is worth knowing: another app can declare the same scheme, and Android will offer a chooser rather than refuse. PKCE is what makes an intercepted code useless. An App Link closes it properly and costs an `assetlinks.json` on your own server's domain. **A fingerprint releases the device key, and re-enrolling a fingerprint destroys it.** The key is generated with `setInvalidatedByBiometricEnrollment`, which is what stops somebody who can add their own fingerprint to an unlocked phone from inheriting the vault. The cost is that adding a finger legitimately means typing the passphrase again and re-registering — which the unlock screen treats as ordinary, because it is. **A notification appears while a shell is open.** Android stops backgrounded processes, and the desktop client's promise that locking the vault does not close your shells is only true here behind a foreground service. The notification is the price of that promise; it goes when the last shell does. **The recovery code screen blocks screenshots.** `FLAG_SECURE` is raised for that one state and lowered again afterwards, so the screen's own claim is true and a shell is still screenshotable. It stops the accident worth stopping — the only copy of an unrecoverable code landing in a cloud photo library, or in the recent-apps thumbnail — and stops nothing determined, since a second phone photographs a screen perfectly well. **Nothing has been run on a device.** It compiles, links, packages, and carries the right native libraries for arm64 — that is verified, and CI verifies it on every change. Everything about its runtime behaviour is not, and `docs/android-port.md` says which claims those are. ### 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 is Windows-first, but **the full suite now runs on Linux too**, and CI runs it there on every change. Getting there cost three fixes rather than none, and each was a real difference instead of a test being fussy: the local file pane built its roots bar from every mount the kernel holds, one assertion recognised the profile directory only by its Windows capitalisation, and the layout harness pinned a COM error that only Windows raises. macOS is still unverified. Anything known or suspected to differ 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. Android has been audited and scoped, but not started: [`docs/android-port.md`](docs/android-port.md) records what ports as it stands (most of the core), what does not (most of the interface), the decisions taken about what an Android client would be — phone-first, keychain plus a terminal — and the spike that gates all of it. ### Conventions the build enforces - Warnings are errors, formatting included: `IDE0055` is an error in `.editorconfig`, so a misformatted file fails the build itself rather than a separate CI step. - 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). The two gaps this milestone shipped with have both closed since. A vault credential can be created — from the Vault screen, or from the REMEMBER tick beside the connect password, which saves it and binds the host to it once the remote has accepted — so password authentication asks once rather than every time. And a device key is registered where the machine can hold one: Windows keeps it in the TPM under a CNG policy that makes the consent dialog a condition of *using* the key rather than a prompt this application draws, which is stronger than [ADR 0007](docs/adr/0007-device-key-protection.md) originally described and is why that ADR was corrected. What is left is the floor rather than a gap: a machine with no TPM, or a desktop that is not Windows, gets a store that reports itself unavailable and keeps asking for the passphrase — the honest answer rather than a degraded one. 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. *Organising done:* hosts can be filed into groups, and commands can be saved as snippets. Both are ordinary synced items — encrypted, merged and pushed like every other — and both are invisible until used: a keychain with no groups draws the flat host list it always did. Two things about them are deliberate. Group membership is a field on the *host* rather than a member list on the group, so filing two machines at once on two laptops is two independent writes instead of one contested one; and a group carries the port, username and key the hosts inside it fall back to, read at connect time rather than copied in, so changing one changes every host that never overrode it. Groups nest, and that reverses an earlier decision worth recording. They were flat because a parent pointer merged field by field lets two offline clients build a cycle nothing upstream can see — the pointer is inside the payload, so the server cannot read it, and the merge resolves one item against one item and never sees the pair. That is still true. What changed is that inheritance made the chain something the *connect* path walks, so the answer had to be a walk that terminates whatever it is handed: every walk carries a visited set and stops at a repeat, which degrades a cycle to a group reading as a root rather than to a shell that never opens. Given a walk that had to be cycle-safe anyway, refusing to nest bought nothing. Inserting a snippet types it at the prompt and stops. Pressing Enter is a per-snippet decision, off by default, and the reason is worth stating: a terminal is one input stream with no notion of being at a prompt — the remote may be in an editor, or at a password prompt with the echo off — so this client cannot honestly say "run this command", only "type this into whatever is there". *Logs done:* what has been connected to, and what has been changed in the keychain. Both are synced, encrypted items rather than local files, because the point of them is auditing a shared vault — a log only one machine can read is a diagnostic, not an audit trail. Two consequences are stated rather than implied. The connection log records the host's name, the address dialled, when, for how long and by which account on which machine — but **not** the SSH username, which is a detail of the host and is in the host's own logs. The keychain log records the **names** of the fields an edit touched and never their contents. What that costs is in [ADR 0001](docs/adr/0001-e2ee-trust-model.md): the server still cannot read a single field, but one row per connection with a server-side timestamp tells it your connection rate and the hours you work. Retention bounds it — ninety days or five thousand entries per kind, whichever bites first. *Buckets done:* S3-compatible object storage is a second kind of remote on the Files screen, beside a host. Prefixes are directories, objects are files, and transfers go the same way through the same queue. The bucket, its endpoint and its keys are a keychain item like any other, encrypted end to end — which matters more than usual here, because for anybody self-hosting MinIO or Ceph the endpoint is an address on their own network. Three things a bucket cannot do are refused with a reason rather than approximated: there are no directories, an interrupted **upload** starts again rather than resuming (an object cannot be written from the middle), and a rename is a copy and a delete rather than one atomic operation. Downloads do resume — a ranged GET is part of the protocol, which is the one place a bucket beats SFTP. - **M3 — teams**, sharing, ACLs. *Done.* Teams with roles, a public-key directory, the append-only key log served for clients to verify against, team-owned vaults, and vault key grants wrapped by a client and stored opaquely by the server. `VaultAccessService` now resolves team membership to permissions, so a viewer may pull and may not push; the desktop client reads and syncs every vault it holds a key for, and a real TEAMS screen replaces the placeholder. A team can be renamed, handed to another member, and archived once it owns no vaults; a member row carries when that account was last here; and an address with no account on this deployment can be invited, joining the moment somebody signs in with it. See [Working as a team](#working-as-a-team) for the one distinction the whole design rests on, and the limits worth knowing before you rely on it; the reasoning is in [ADR 0009](docs/adr/0009-team-access-model.md). **Membership changes now move the keys, not just the flag.** Adding somebody wraps every team vault the adding machine can open to them — every generation of each, so they can read the vault's history and not only what happens next. Removing somebody revokes their grants, advances each vault it can open to a fresh key generation in one server transaction, and wraps that key to the members who remain. What a rotation buys is exact: everything written from then on is unreadable to the person who left. The items already stored are then re-sealed under the new key as well, item by item and resumably — which is safe to do incrementally precisely because a vault at mixed generations stays readable. A change queued before the rotation is re-sealed as it is pushed, so nothing reaches the server under a superseded key at all. See [ADR 0010](docs/adr/0010-vault-key-rotation.md). **Ownership transfer is here, and it is one write rather than two.** The member you name becomes owner and you become an admin, in a single transaction — because ownership is sole, so promoting first leaves the team owned twice and demoting first leaves it owned by nobody, and there is nobody left with the authority to finish a transfer that stopped in the middle. Nothing else is touched: you keep your vault key grants, because removing the outgoing owner would revoke them and rotate every team vault, which is a much larger act than the one being asked for. - **M4 — hardening and ops**, packaging, self-hosting guide. *Decided ahead of the work, because the first release takes it irreversibly:* who signs the client and where it comes from. [ADR 0011](docs/adr/0011-android-distribution.md) puts the release key with the project rather than with a store, and rules out the arrangement a self-hosted product reaches for by default — the deployment serving the client binary, which hands it to the one party the whole trust model is about. An installed Android app can only ever be updated by a package signed with the same key, so this is the first release's decision to make and nobody else's afterwards. - **M5 — multi-provider OIDC**, identity key rotation, per-item content keys. ## Licence [MIT](LICENSE).