Public Access
643 lines
45 KiB
Markdown
643 lines
45 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#): 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 re-keys the vault, not what is already in it.** Existing items stay sealed under the
|
|
generation they were written with, and everybody still in the team keeps those keys as well as the new
|
|
one — which is what stops a rotation making the vault's own history unreadable. It also means somebody
|
|
who left with a copy of the old key could still open old ciphertext they later got hold of. Re-sealing
|
|
stored items under the new key is the remaining half; 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. Items
|
|
already stored keep the generation they were sealed under and are not re-encrypted — that half is still
|
|
outstanding, and it is safe to add later precisely because a vault at mixed generations stays readable.
|
|
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 flag every team vault for rekey,
|
|
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 0010](docs/adr/0010-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**, key rotation, per-item content keys.
|
|
|
|
## Licence
|
|
|
|
[MIT](LICENSE).
|