Public Access
The Android head skipped a host's forwards because it had nowhere to show what was listening. Its terminal bar now draws a ⇄ for a tab that has forwards, carrying the listening count in green and turning the danger colour when one failed, and it raises a sheet over the same rows the desktop sidebar lists: notation, route, state, failure reason and a tap to switch each off or on. MainWindowViewModel.StartForwards no longer returns early on Android. The sheet is shell state like the connect sheet: it collapses the renderer while up, is lowered by leaving the terminal, and the back gesture closes it first. Forwards ride the shell's keep-alive rather than holding the foreground service themselves, since a forward cannot outlive its shell. They only change the notification, which now counts ports forwarded beside shells connected, fed by a new ForwardsChanged event and ListeningForwardCount. The phone still has no editor for forwards; the More screen card, README and design-import-gaps now say they run here and are edited on the desktop.
1085 lines
79 KiB
Markdown
1085 lines
79 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 existed at the time — 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 and the keys it names when asked; reading a Hetzner
|
|
Cloud project over its API
|
|
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, macOS and Linux 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
|
|
scripts/ release-windows.ps1 — builds, packs and publishes the Windows client
|
|
release-macos.sh — the same, signed and notarized, on a Mac
|
|
ci-linux-rpm.sh — builds the Linux .rpm, then interrogates it
|
|
build/macos/ the entitlements and Info.plist template the macOS bundle is built from
|
|
build/linux/ the RPM spec the Linux package is built from
|
|
```
|
|
|
|
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.
|
|
|
|
## Installing on Windows
|
|
|
|
The desktop client is published on the project's own release page as a `Setup.exe`. It installs per user,
|
|
under `%LOCALAPPDATA%\DodoSSH.Desktop`, and never asks for an administrator.
|
|
|
|
**It will warn you, and here is exactly what the warning means.** The build is not yet signed with a code
|
|
signing certificate, so Windows SmartScreen shows *"Windows protected your PC"* the first time you run the
|
|
installer; **More info → Run anyway** gets past it. That is the honest state of things rather than something
|
|
to click through blindly — it is a statement that Microsoft has not seen this file before, and it will stop
|
|
appearing when the project buys a certificate.
|
|
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) says what that costs and when it happens. The
|
|
warning is once per person: updates from inside the application do not raise it.
|
|
|
|
**Updates.** The client checks the project's release page every six hours, downloads a newer build in the
|
|
background, and then waits. Nothing is ever installed while you are using it — a downloaded update runs
|
|
after a restart you ask for, or the next time you start DodoSSH anyway. Restarting does end every shell you
|
|
have open, which locking deliberately does not, so the choice of when is left to you. You can turn the
|
|
checking off on PREFERENCES → UPDATES.
|
|
|
|
**Where it comes from matters, and it is worth one paragraph.** A DodoSSH server will never offer you the
|
|
client, and one that does is not one to trust. Whoever hands you the binary can hand you a binary that
|
|
copies your passphrase — the client is where your credentials are in plaintext, by construction — and the
|
|
operator of a deployment is precisely the party the trust model is about. An operator may tell you where to
|
|
get it. They are not where it comes from, and the update check inside the application points at the
|
|
project's own forge and nowhere else. See [ADR 0011](docs/adr/0011-android-distribution.md) rule 2.
|
|
|
|
**That address is read anonymously, so the repository behind it has to be public** — on both heads. There is
|
|
no token and there is deliberately nowhere to put one: it would have to be readable before the vault is
|
|
unlocked, and the only file readable then is the plaintext settings file, which is the one place a token may
|
|
not go. If the repository is private, every check answers `404`, and the honest thing the client can do
|
|
about that is say so. It is one line to check:
|
|
|
|
```bash
|
|
curl -so /dev/null -w '%{http_code}\n' https://git.dodotech.cloud/api/v1/repos/DodoTech-Public/DodoSSH
|
|
```
|
|
|
|
`200` and updates work. `404` and they cannot. `/api/v1/version` answering `200` proves only that the forge
|
|
is up, which is what made this look like nothing was wrong for as long as it did.
|
|
|
|
**Uninstalling removes the application and leaves your vault cache** at `%LOCALAPPDATA%\DodoSSH`, so
|
|
reinstalling asks for your passphrase rather than starting over. Use **Sign out** inside the application if
|
|
you want the machine to genuinely forget everything — an uninstall is not a sign-out, and does not withdraw
|
|
this machine's device key from your account.
|
|
|
|
## Installing on macOS
|
|
|
|
A `.pkg` on the same release page, for Apple Silicon. Everything above about where a client may come from,
|
|
about the update check and about uninstalling applies unchanged; what differs is worth three short
|
|
paragraphs.
|
|
|
|
**It is signed and notarized, so there is no warning to click past.** That is not generosity — macOS refuses
|
|
to open an un-notarized download outright rather than warning about it, so unlike the Windows build there
|
|
was never an unsigned option. If you *do* see "cannot be opened because Apple cannot check it for malicious
|
|
software", the file did not come from the project's release page, and that is worth taking literally.
|
|
|
|
**Apple Silicon only for now.** An Intel package is a small amount of work and no one here has an Intel Mac
|
|
to check it on, and this project does not ship desktop builds nobody has run — see
|
|
[docs/manual-checks.md](docs/manual-checks.md). Under Rosetta the arm64 build will not run; there is no
|
|
graceful version of that, and the honest answer is that the platform is not covered yet.
|
|
|
|
**Touch ID can stand in for your passphrase**, on a Mac with a Secure Enclave. The key that unwraps your
|
|
device key is generated inside the enclave and never leaves it, and the enclave — not DodoSSH — is what
|
|
requires your fingerprint or login password before it will use it. Cancel the prompt and you get the
|
|
passphrase screen, always. The application lives at `/Applications/DodoSSH.Desktop.app` and your vault cache
|
|
at `~/Library/Application Support/DodoSSH`, which are deliberately two different places so that removing the
|
|
first never touches the second.
|
|
|
|
## Installing on Linux
|
|
|
|
An `.rpm` for x86_64, and the one platform where you get it from `main` rather than from a release — see
|
|
[the nightly build](#the-nightly-desktop-build) below, which is where the Linux package is published today.
|
|
|
|
```bash
|
|
sudo dnf install ./dodossh-nightly-*.x86_64.rpm
|
|
```
|
|
|
|
Everything above about where a client may come from applies unchanged. Four things differ, and the first
|
|
two are the ones to read before installing.
|
|
|
|
**It checks for updates and it installs nothing.** The Windows and macOS builds carry Velopack and replace
|
|
their own files; this one never does. It checks the project's forge every six hours, and when there is a
|
|
newer build it downloads it, checks its digest, and hands the file to **PackageKit** — which raises the
|
|
usual "authentication is required to install software" prompt. You may say no, and nothing is replaced until
|
|
you say yes. Nothing restarts either: replacing files under a running process is ordinary here, so the build
|
|
you have keeps running until you close it.
|
|
|
|
That split is the point rather than a compromise. Installing software is your package manager's job and it
|
|
already answers to you; noticing that a build exists is the part `dnf` cannot do for a package that is not
|
|
in a repository. What DodoSSH must never become is a second thing that can put software on your machine on
|
|
somebody else's say-so, which is why the address it checks is a constant in the binary and can never be a
|
|
DodoSSH server — [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) rules 4 and 11.
|
|
|
|
The gap that remains: there is **no package repository**, so `dnf upgrade` alone will never bring you a
|
|
newer DodoSSH. If you turn the check off on PREFERENCES → UPDATES, nothing else will tell you.
|
|
|
|
**Your PIN can stand in for your passphrase, if this machine has a TPM you can reach.** The device key is
|
|
sealed into the TPM and released only against a PIN that **the TPM itself checks** — it counts wrong answers
|
|
and stops responding after a few, which is what makes four digits enough and what makes a stolen disk
|
|
useless. Two honest caveats, both in
|
|
[ADR 0007](docs/adr/0007-device-key-protection.md) decision 4:
|
|
|
|
- **It is weaker than Windows Hello or Touch ID**, and the difference does not show. Those draw their prompt
|
|
from inside the operating system; this one is a window DodoSSH draws, so something already running as you
|
|
could imitate it. It could imitate the passphrase screen too, which is worth far more — but the guarantee
|
|
is not the same and this page will not pretend it is.
|
|
- **The package grants your session access to the TPM, and you should know what that means.** Fedora leaves
|
|
`/dev/tpmrm0` readable only by the `tss` group, so this would otherwise need an administrator and a fresh
|
|
login. Instead the package installs a udev rule tagging the device `uaccess` — the same mechanism your
|
|
webcam and your FIDO key already use, which grants an ACL to **whoever is logged in at the machine** and
|
|
takes it away when they log out. Not over SSH, not to a user switched away on another VT, not to daemons.
|
|
|
|
The cost is not secrecy — TPM objects are protected by their own PINs — it is that anyone at the keyboard
|
|
can make failed attempts, and a TPM answers enough of those by locking out *everyone*. **If this machine
|
|
unlocks its disk with the TPM at boot**, that means somebody sitting at it could stop the next boot from
|
|
unlocking until the lockout recovers. If you would rather not have that, one command puts it back:
|
|
|
|
```bash
|
|
sudo ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules
|
|
```
|
|
|
|
DodoSSH then behaves as though the machine had no TPM: no PIN offered, passphrase every time.
|
|
|
|
See [docs/unlocking-without-the-passphrase.md](docs/unlocking-without-the-passphrase.md).
|
|
|
|
**It needs WebKitGTK, because the terminal is a WebView.** Specifically `webkit2gtk4.1`, which `dnf` pulls
|
|
in for you. Fedora 44 is the distribution the head has actually been run on. RHEL 9 and its rebuilds ship
|
|
the older 4.0 API line under a different library name and the package deliberately does not claim them —
|
|
`dnf` will refuse with *nothing provides libwebkit2gtk-4.1.so.0*, which is a better answer than installing
|
|
and drawing a blank terminal. This project does not ship desktop builds nobody has run, on any platform.
|
|
|
|
**Where things go.** The application at `/opt/DodoSSH`, launched as `dodossh` or from your menu; your vault
|
|
cache at `$XDG_DATA_HOME/dodossh`, usually `~/.local/share/dodossh`. Two different places on purpose:
|
|
`dnf remove` takes the first and never touches the second, so reinstalling asks for your passphrase rather
|
|
than starting over. Use **Sign out** inside the application to make the machine genuinely forget.
|
|
|
|
Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine, and
|
|
`scripts/release-macos.sh` on a Mac. Deliberately not a CI job; ADR 0013 decision 3 explains why, and it is
|
|
not only that the runners are Linux.
|
|
|
|
### The nightly desktop build
|
|
|
|
There is a second Windows build, published by CI from `main` on every push, on the `nightly-desktop`
|
|
release. It installs **beside** the release build rather than over it — its own entry called *DodoSSH
|
|
Nightly*, its own install directory, and its own profile at `%LOCALAPPDATA%\DodoSSH.Nightly` — so trying it
|
|
costs nothing you are relying on. It signs in separately and appears to your account as a new device,
|
|
because it genuinely is a second installation.
|
|
|
|
**The Linux `.rpm` is on the same release**, built from the same commit and versioned the same way, as
|
|
`dodossh-nightly`. It installs beside a release package for the same reasons and by the same four
|
|
separations — a different package name, a tree at `/opt/DodoSSH.Nightly`, a `dodossh-nightly` launcher with
|
|
its own menu entry, and a profile at `~/.local/share/dodossh.nightly`. Two RPMs owning different paths
|
|
cannot replace one another, so `dnf` will keep both rather than treating one as an upgrade of the other.
|
|
|
|
It is the only Linux package this project publishes at all today: there is no `v*`-tagged Linux release
|
|
yet, because no person has walked a Linux build through
|
|
[docs/manual-checks.md](docs/manual-checks.md). What CI proves on every push to `main` is that the release
|
|
package still *builds* — the same arrangement Windows and macOS have had since the beginning, where the
|
|
package is built on every commit and uploaded nowhere.
|
|
|
|
**Installing it means trusting everyone who can write a release on this repository, including CI.** The
|
|
updater applies what the feed serves without verifying a signature, so anyone who can change a workflow
|
|
file here can put a build on every nightly machine. That is an acceptable trade for a build you are trying
|
|
and not one for a build holding your infrastructure credentials, which is the entire reason the two
|
|
channels exist and cannot see each other. The release build refuses prereleases and reads a different feed,
|
|
so nothing published here can ever reach it. Same arrangement, and same reasoning, as the phone's nightly —
|
|
[ADR 0014](docs/adr/0014-android-updates.md), and [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md)
|
|
decision 9.
|
|
|
|
## 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. **COPY CODE** puts it on the clipboard so it can go
|
|
straight into a password manager: it is the one secret this application deliberately offers to a clipboard,
|
|
because there is nowhere better for it to go and the alternative people actually reach for is a photograph
|
|
of the screen. 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.
|
|
|
|
**Importing an `~/.ssh/config` can bring the keys with it, and only if you ask.** Preferences → IMPORT
|
|
HOSTS scans the file and shows what it found; nothing is stored until you press the button. Below the list
|
|
there is one tick — *Also import the private keys these hosts point at* — and it starts off.
|
|
|
|
With it off, an `IdentityFile` becomes a note recording the path, and the host asks for a password until you
|
|
bind it to a key yourself. With it on, IMPORT reads each host's first `IdentityFile` out of `~/.ssh`, stores
|
|
it in the vault encrypted like any other key, and binds the host to it. One key is stored per **file**
|
|
however many entries name it, and a file already in your keychain is bound to rather than stored twice, so
|
|
running the import again does not fill the keychain with copies.
|
|
|
|
**This is the only place DodoSSH opens a private key you did not point it at file by file**, which is why
|
|
the tick is off, why the sentence beside it names the directory, and why nothing is read during SCAN — you
|
|
can tick it, read what it says and untick it, and no key will have been opened. A key protected by a
|
|
passphrase comes in without one, because nothing on disk says what the passphrase is; the report under the
|
|
button names those files, and the keychain's editor is where the passphrase goes. A file that is not on this
|
|
machine — the ordinary case for a config carried from another one — leaves its host imported and unbound,
|
|
and is named in the same report.
|
|
|
|
### 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).
|
|
|
|
### Forwarding ports
|
|
|
|
A host can carry **port forwards**, added under PORT FORWARDS in its editor, in the three kinds OpenSSH has:
|
|
**local** (`-L`, a port here reaching a machine the remote can see), **remote** (`-R`, a port on the remote
|
|
reaching back to one this machine can see) and **dynamic** (`-D`, a SOCKS proxy here whose connections leave
|
|
from the remote). They are saved in the host's encrypted payload, so they sync, merge and are shared with the
|
|
host, and they start with every terminal opened to it, on either head. The session sidebar lists them under
|
|
FORWARDS with the state of each, and the status bar counts the ones listening; on the phone the same list is a
|
|
sheet off the terminal's bar.
|
|
|
|
Three things about them are worth expecting rather than discovering.
|
|
|
|
**Everything listens on localhost, and there is no setting for it.** A forward is a field any member of a
|
|
shared keychain can fill in, and one bound to every interface would hand the remote's network to anybody who
|
|
can reach your machine for as long as the terminal is open. A remote forward asks the remote for its loopback
|
|
too; whether the remote honours that is its own `GatewayPorts`.
|
|
|
|
**A forward lives as long as its terminal.** It runs over that tab's connection, so closing the tab, losing
|
|
the connection or reconnecting stops it — a reconnect starts the host's forwards again on the new session. A
|
|
second tab to the same host runs the same forwards, and they fail there because the ports are taken; the row
|
|
says so, and the terminal is unaffected. Any row can be switched off and on while its session is up.
|
|
|
|
**No byte counts**, which the design draws: SSH.NET keeps none, so a number there would be invented. The
|
|
phone runs forwards too, listed behind a `⇄` in its terminal's bar, but cannot edit them yet; see
|
|
[The Android head](#the-android-head).
|
|
|
|
### Sharing a vault
|
|
|
|
**VAULTS** in the nav rail lists every vault you can see, makes new ones, adds people to one,
|
|
shares its key, renames it and hands it over. One distinction runs through the whole screen and is worth
|
|
having before you use it.
|
|
|
|
**A vault is the thing you make, and the group of people is behind it.** The server authorises through a
|
|
*team* — `VaultAccessService` resolves a shared vault through `team_membership`, and every membership call
|
|
names a team id — but nothing asks you to make one: naming a vault makes the membership list that carries
|
|
it, named after the vault and owned by you. So the thing you came to share is the thing you create, and
|
|
"which team is this in" stops being a question you need an answer to before you can share four servers with
|
|
two colleagues. Renaming the vault renames that membership list with it, as long as it carries nothing else.
|
|
|
|
The one case where the distinction resurfaces is a team owning several vaults, which this screen cannot
|
|
produce and does not hide: the members section then says so, because adding somebody to one of those vaults
|
|
adds them to all of them.
|
|
|
|
**Adding somebody to a vault and giving them its 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 vault appears in their
|
|
list immediately. It cannot make it 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.
|
|
|
|
**Only somebody who already has an account here can be added, and the refusal says what to do about it.**
|
|
There is one button — **ADD** — and it takes an email address. If the directory knows it, that account is
|
|
added and its public key comes back with it. If the directory does not, the address is still tried against
|
|
the server, because the directory lists only accounts that have *published a key* — everybody between
|
|
their first sign-in and setting a machine up is missing from it, and reading that silence as "no account
|
|
here" would refuse somebody standing right beside you.
|
|
|
|
What is refused is an address with no account at all. The status line names the address and says the
|
|
remedy: **ask them to sign in to this server once — that is what creates the account — and then add
|
|
them.** Nothing is held for them in the meantime.
|
|
|
|
**That is a deliberate limit rather than a missing feature.** There used to be an invitation here: a
|
|
standing instruction that the next account signing in with that address joined the vault, taken up on the
|
|
strength of the token's `email_verified` claim. It worked, and it was the one place in this product where
|
|
access was decided by an assertion about an address rather than by somebody naming an account — so the
|
|
whole of its defence was a boolean sent by an identity provider. It is gone. Everything that grants access
|
|
now starts with a person who already exists. The reasoning is in
|
|
[ADR 0009](docs/adr/0009-team-access-model.md).
|
|
|
|
Adding somebody still grants nothing readable: press **SHARE KEY** afterwards, or add them from a machine
|
|
that holds the vault key and it happens in the same step. A member with no key of their own yet — added
|
|
before they finished setting up — says so on their row, and cannot be shared with until they have one.
|
|
|
|
**Ownership is sole, and handing a vault 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 it 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 the vault, and somebody handing a vault over is
|
|
usually staying in it.
|
|
|
|
Six limits, stated rather than discovered:
|
|
|
|
- **Deleting a vault does not reach a machine that has already synced it.** An admin or the owner can
|
|
delete a shared vault: it leaves everybody's list at once, every key to it is withdrawn, and the
|
|
membership list behind it is archived when it existed to carry that vault alone. What it cannot do is
|
|
take back the copy a colleague pulled yesterday — the same limit revocation has, for the same reason —
|
|
and the question you are asked before it happens says so. Your personal vault is refused: everything
|
|
filed nowhere else lives in it, and there is no way to make another.
|
|
- **Removing a member is not retroactive.** It revokes their grants, rotates every vault behind that
|
|
membership list 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
|
|
vault 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 somebody 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 shared vault's host is recorded and
|
|
used from your own vault, not the shared one, so a colleague 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 shared
|
|
host's key once on each of their machines. Shared vaults' pins are still *listed* on the Pins 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, and which one is asked at the moment the item is made. **A host's
|
|
editor has its own picker**, beside the name, because it is the decision on that form that decides who can
|
|
read the host. It is absent when you edit an existing one rather than present and refusing, and that is not
|
|
because the host is stuck: **a host can be moved to another vault** — "Move to another vault…" in the detail
|
|
pane's menu on the desktop, MOVE beside EDIT on the phone. It is a separate act because it is not a save.
|
|
The two vaults are encrypted under different keys, so a move is a re-seal into one and a tombstone in the
|
|
other; the host gets a new id, and **its group and its tags stay behind**, because both are items of the
|
|
vault it is leaving. A picker inside the form would do all of that as a side effect of correcting a port.
|
|
What a move cannot do is reach a machine that has already synced the host, which is the same limit
|
|
everything else about revocation has. Buckets take theirs from a standing "new items go to" picker on the
|
|
Keychain screen and cannot be moved yet.
|
|
|
|
**The move asks whether the key comes too**, because that is the half a host's move could not settle on its
|
|
own. A binding resolves across every vault you can read, so the moved host goes on working for *you* either
|
|
way — but the people you have just shared it with hold one vault's key, and a host whose key stayed in your
|
|
personal vault is one they cannot connect with. The tick box beside the picker is unticked, and stays that
|
|
way on purpose: moving a key into a team's vault hands it to everybody who holds that key, and a disclosure
|
|
is chosen rather than defaulted into. Under it is the count of everything else that authenticates with that
|
|
key, which is what makes the answer decidable — a key twenty machines use is a different decision from one
|
|
nothing else touches. Left unticked, the sentence afterwards names the key that is now outside the
|
|
destination. A key the host only *inherits* from its group counts too, and is written onto the host on the
|
|
way across: the group stays behind, so a host that arrived naming nothing would authenticate with nothing.
|
|
|
|
**A key or a password can also be moved on its own** — MOVE beside EDIT and DELETE on the Keychain screen,
|
|
on both heads, for keys and passwords only. It is the same re-seal and tombstone, and it takes a new id in
|
|
the destination, so **everything that named it is re-aimed at where it went**: every host bound to it and
|
|
every group lending it as a default, across every vault you can write to. Without that the move would be a
|
|
deletion with extra steps, since a host bound to something its vault no longer holds refuses to connect
|
|
rather than falling back to a typed password. Anything that cannot be rewritten here — an item from a newer
|
|
client, or one in a vault you can only read — is left naming the old item and is counted in the sentence
|
|
afterwards. The panel says what points at the key before you press it, not after.
|
|
|
|
**A group can be moved too, and it takes its contents with it** — "Move to another vault…" on the group
|
|
card's right-click menu, beside Open, Edit and Delete, which is the whole of what can be done to a group on
|
|
the desktop. The phone has the same three on a sheet raised from the group's heading, since it draws no
|
|
cards and has no right-click; it does not have Open, because its list shows the whole tree flat and there is
|
|
nothing to open a group into. It is the same re-seal
|
|
and tombstone underneath, applied to every item involved: the group, the groups nested inside it, and every
|
|
host filed under any of them, each taking a new id in the destination. Moving less than that was never
|
|
coherent — the machines and the child groups are items of the vault the group is leaving, so a group that
|
|
travelled alone would leave half a shelf behind. What stays is the group it was itself nested under, which
|
|
belongs to the old vault, so it arrives at the top level; the hosts' tags stay for the same reason. Keys and
|
|
passwords are kept, because those genuinely resolve across vaults, and the sentence afterwards names any
|
|
that are now outside the destination.
|
|
|
|
**Deleting a group asks what should become of the hosts under it.** The default answer keeps them: the
|
|
reference is cleared and they move to UNGROUPED. Ticking the box deletes them with it. Both answers are a
|
|
change — the deletion used to leave the hosts holding an id that no longer resolved, which looked the same
|
|
and cost nothing, and stopped being the right shape once the deletion could take them with it. A group with
|
|
nothing filed under it is not asked. Whichever answer is given, the groups nested inside take the deleted
|
|
group's place in the tree rather than being orphaned to the top level.
|
|
Both default to your personal vault and neither moves on its own, because an item put in a shared vault is
|
|
visible to everybody holding that vault's key. Choosing a vault in the host editor also decides which groups
|
|
it can be filed under: a group is an item like any other and lives in exactly one vault.
|
|
|
|
### Changes that do not wait
|
|
|
|
A client holds a WebSocket open to the server — `GET /api/v1/events`, subprotocol
|
|
`dodossh.events.v1` — and the server sends a line down it whenever something you can read has moved. The
|
|
client's answer is the same delta pull it would have run on its timer, only now rather than in up to a
|
|
minute. Two things you can see: an edit somebody else makes appears while you are looking at the list, and
|
|
a vault shared with you turns up as soon as they share it.
|
|
|
|
**What is on that socket is a notice, not your data.** A frame says which vault changed and how far its
|
|
change log has got, and nothing else: no item, no ciphertext, not even which item it was. That is the
|
|
decision the rest of this rests on, and it is deliberate twice over — the server has nothing else it
|
|
*could* send, and keeping it that way means there is still exactly one path that applies a change to your
|
|
keychain, so the socket can be wrong or absent without anything being applied incorrectly.
|
|
|
|
**Polling is still there and is still what guarantees a pass.** The minute timer is unchanged. A network
|
|
that eats WebSockets, a server with `Events:Enabled` off, an older server, a proxy that will not upgrade, a
|
|
notice dropped because your machine was too slow to read it — every one of those leaves you with exactly
|
|
what this product did before the socket existed. Nothing is reachable only this way, and nothing is
|
|
supposed to become so.
|
|
|
|
Three limits are worth knowing rather than discovering:
|
|
|
|
- **One node.** Fan-out is in-process, so a deployment running more than one API replica only pushes for
|
|
writes that its own replica handled. The rest arrive on the timer. The seam for a PostgreSQL
|
|
`LISTEN`/`NOTIFY` backplane is in place and is not implemented, because an untested backplane would be
|
|
worse than a documented gap.
|
|
- **The socket does not outlive your access token.** It is closed at the token's expiry and the client
|
|
reconnects with a fresh one, which is a gap you will not see. That, plus re-reading your vault list every
|
|
few minutes, is what bounds how long a withdrawn grant can keep producing notices — and what it bounds is
|
|
*metadata*, because reading a vault needs a key the server has never held.
|
|
- **You are told about your own writes.** Your client pushed, so it has already pulled; the extra pass finds
|
|
nothing. Notices are coalesced over a quarter of a second so that a burst is one pass rather than a dozen.
|
|
|
|
The reasoning, including why this is a WebSocket rather than server-sent events and where a shared terminal
|
|
session will attach to it, is in [ADR 0012](docs/adr/0012-realtime-push.md).
|
|
|
|
### 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, vaults 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. Beside the `+`, for a tab whose host has port forwards, a `⇄`
|
|
opens them; see below. The system back gesture does what the arrow does, and lowers either sheet first if
|
|
one 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.
|
|
|
|
**On the phone's host list a tap connects.** It used to choose the machine and raise a bar over the bottom of
|
|
the list — a password box, CONNECT, EDIT, MOVE and DELETE — which put five controls in the way of the one
|
|
thing tapping a machine's name obviously means. So the gestures split: a tap connects, a **long press** raises
|
|
that bar, and the **pencil in the header** edits whichever host is chosen. The one tap that cannot finish is a
|
|
host that authenticates with a password you type, because there is nowhere on a list to type it — that tap
|
|
raises the bar with the box in it and says so, and tapping the row again once it is filled in connects.
|
|
|
|
Hosts and groups are made, corrected and taken
|
|
away here now, from a floating + on the Hosts screen and a menu on each group's heading, and both editors are
|
|
cards in the list's own row rather than dialogs, so the form never covers the thing it is about. Every
|
|
question a deletion asks is the desktop's, counted the same way and drawn in the place the buttons that
|
|
asked it were — including the tick deciding whether a group's machines go with it. 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. **VAULTS does have one**, behind MORE, and it is there for a reason the design could not
|
|
have anticipated: a vault arrives without being asked for — somebody wraps its key to you — so the person
|
|
it arrives for 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. **Somebody can be added to
|
|
a vault from the phone too**, with the same box, the same role and the same sentence about what adding does
|
|
not do — and if that phone can open the vault, it wraps the key in the same press. What the phone still
|
|
does not draw is anything that takes access away: REMOVE and WITHDRAW KEY act on the first press, and an
|
|
irreversible revocation under a thumb with its explanation in a tooltip nothing can show is the wrong
|
|
trade.
|
|
|
|
**On a surface wider than 600dp it lays itself out like the desktop.** A tablet, an unfolded foldable, a
|
|
phone in landscape or a freeform window gets a nav rail down the left carrying every destination at once,
|
|
and the three-entry bottom bar stands down with the SETTINGS hub it led to — a hub is an extra tap between
|
|
you and a screen there is visibly room for. Below that width nothing changes. The one number is Android's
|
|
own boundary between a compact window and a medium one, measured in the density-independent units Avalonia
|
|
lays out in.
|
|
|
|
What it does *not* yet do is use the extra width inside a screen: the host list is still one column at any
|
|
size. Two columns needs the row model to change — the list is headings and hosts in one sequence, and a
|
|
heading has to span — which is shared with the desktop and is its own piece of work.
|
|
|
|
**Port forwarding runs here too, and is edited on the desktop.** A host's forwards start with every
|
|
terminal the phone opens to it, as they do on the desktop. The terminal's bar grows a `⇄` for a tab that has
|
|
any, carrying the number listening in green, or turning red when one could not start; it opens a sheet
|
|
listing the tab's forwards with their route, their state, the reason a failed one gave, and a tap on each to
|
|
switch it off or on — the desktop sidebar's FORWARDS section, over the same rows. The v2 design's whole
|
|
FORWARDING screen is not drawn, for the reason the desktop has none: a forward belongs to a host and runs
|
|
with a session, and both already have a place. What the phone has not got is an editor for them — its host
|
|
form has no PORT FORWARDS section — so they are added on the desktop and arrive with the host; the More
|
|
screen says so in a paragraph.
|
|
|
|
Forwards ride the **keep-alive** a shell already has. A forward runs over its tab's connection, so the
|
|
foreground service that holds a shell open in the background is holding its forwards open too, and nothing
|
|
else needs to: the service is never started for a forward alone, because there is never a listening forward
|
|
without a live shell. What forwards change is the notification, which counts them beside the shells —
|
|
`1 shell connected · 2 ports forwarded` — because a phone in a pocket holding ports open is something its
|
|
owner should be able to see. Local and SOCKS forwards listen on the phone's own loopback, which every app on
|
|
the phone can reach; that is what makes one useful here — a database client on the phone reaching a server
|
|
behind the remote — and it is the same exposure the desktop's localhost has to its own processes. See
|
|
[Forwarding ports](#forwarding-ports) and [`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
|
|
|
|
- One version for the whole repository, derived from the nearest `v*` tag by MinVer. A tag build whose
|
|
computed version disagrees with the tag fails CI, and every checkout uses `fetch-depth: 0` — without it
|
|
MinVer answers `0.0.0-alpha.0.N` rather than failing, and a wrong version here is a client that never
|
|
updates.
|
|
|
|
- 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.
|
|
|
|
*Relay done:* a host with "Connect through the server relay" ticked is reached through the deployment,
|
|
for a machine this one cannot dial. The server issues a single-use, thirty-second ticket for that host
|
|
alone, resolves its address and checks it against a deny list with no configuration escape — loopback,
|
|
link-local, metadata endpoints — and then pipes raw TCP; the SSH session still ends on your machine, so
|
|
the relay sees ciphertext. It is off by default (`Relay:Enabled`), and it only helps when the server can
|
|
reach what you cannot. See [ADR 0004](docs/adr/0004-relay-authorization.md).
|
|
|
|
*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.
|
|
|
|
Membership living on the host has one further consequence, and it took two goes to settle. Deleting a
|
|
group could not clear it without rewriting every host under the heading, so at first it did not: the hosts
|
|
kept an id that resolved to nothing and turned up under UNGROUPED, which reads identically and costs no
|
|
writes. That held until the deletion had to be able to take the hosts *with* it — a group is sometimes a
|
|
heading being tidied away and sometimes a project that has been decommissioned, and nothing in the code can
|
|
tell which. Once a deletion knows which hosts it means, leaving them naming something that has gone is a
|
|
state kept for no reason, so both answers now write: N deletions, or N hosts with the reference cleared.
|
|
The dangling case still has to be survived everywhere it is read, because a group deleted on *another*
|
|
machine arrives exactly that way.
|
|
|
|
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.
|
|
|
|
*Realtime done:* a WebSocket the client holds open, over which the server says which vault has moved so a
|
|
pull happens now rather than within the minute. What crosses it is a notice and never an item, which is
|
|
what keeps one code path applying changes and makes a dropped socket cost latency and nothing else — the
|
|
timer is unchanged and is still the guarantee. Two limits are stated rather than implied: fan-out is
|
|
in-process, so a multi-replica deployment falls back to the timer for writes another replica handled, and
|
|
a socket is closed at its access token's expiry rather than outliving the credential that authorised it.
|
|
See [Changes that do not wait](#changes-that-do-not-wait) and
|
|
[ADR 0012](docs/adr/0012-realtime-push.md).
|
|
|
|
It is also the transport a **shared terminal session** will use — one person's shell, watched or driven by
|
|
somebody else. Nothing of that exists yet, and ADR 0012 records the one decision made early so it need not
|
|
be renegotiated: session data will be binary frames on this same socket, because base64 in a JSON envelope
|
|
is the wrong shape for the one payload here that is continuous rather than occasional.
|
|
- **M3 — shared vaults**, sharing, ACLs. *Done.* Membership with roles, a public-key directory, the
|
|
append-only key log served for clients to verify against, shared 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 VAULTS screen replaces the placeholder. The screen is
|
|
vault-shaped rather than team-shaped: naming a vault makes the membership list that carries it, so the
|
|
team is behind the vault rather than a thing anybody has to create first. A vault can be renamed, deleted
|
|
and handed to another member; and a member row carries when that account was last here. Only an account
|
|
that already exists can be added — an address is not a way into a vault. See
|
|
[Sharing a vault](#sharing-a-vault) 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).
|
|
|
|
**A vault shared with you arrives at once**, with no sign-in and nothing to press. Each pass asks the
|
|
server which vaults this account can reach before syncing the ones it already knows — which is also how a
|
|
vault that has been deleted, or one whose grant was withdrawn, stops being listed — and the server now
|
|
says so the moment somebody wraps a key to you rather than leaving it for the next pass. Without a
|
|
reachable socket that becomes "within the minute", which is what it always was; see
|
|
[Changes that do not wait](#changes-that-do-not-wait).
|
|
|
|
**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.
|
|
|
|
**The Windows desktop half is built.** Velopack packaging for `win-x64`, a `Setup.exe` that installs per
|
|
user with no administrator prompt, and a client that checks the project's own forge every six hours,
|
|
fetches a newer build in the background, and then waits for a restart the user presses — because a
|
|
restart ends every shell, and this application has gone to some trouble to make locking not do that. The
|
|
version is now one number for the whole repository, derived from the `v*` tag by MinVer, which is also
|
|
the first time the API has reported a true `serverVersion`. Releases are cut by a person rather than by
|
|
CI: the token that writes a release is, for an updater that trusts its feed, the same capability as the
|
|
signing key, which [ADR 0011](docs/adr/0011-android-distribution.md) rule 1 keeps off runners. See
|
|
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md), and
|
|
[Installing on Windows](#installing-on-windows) for what a user sees.
|
|
|
|
**The macOS half is built on the same machinery**, and signed from the start because Gatekeeper leaves no
|
|
choice: `scripts/release-macos.sh` publishes, signs every native library, notarizes with Apple and staples
|
|
the ticket before it will hand anything over, and refuses to upload until a person has installed it. The
|
|
device key is held in the Secure Enclave behind Touch ID. CI publishes `osx-arm64` and builds the `.app`
|
|
on every main build to prove it still packages, and uploads nothing. See
|
|
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 10 and
|
|
[Installing on macOS](#installing-on-macos).
|
|
|
|
**The Linux half checks, and the package manager installs.** An `.rpm` for x86_64, built and checked on
|
|
every push to `main` and published to the nightly release, installing to `/opt` with a menu entry and a
|
|
`dodossh` launcher. It carries no Velopack and replaces nothing itself: it notices a newer build on the
|
|
project's forge, downloads it, verifies its digest and hands the file to PackageKit, which asks before it
|
|
installs. Splitting it that way keeps the part that can put software on your machine where it already
|
|
was — with your package manager — while fixing the half `dnf` cannot do for a package that is in no
|
|
repository. The device key is sealed into the TPM behind a PIN **the TPM checks and counts failures
|
|
against**, which is what makes four digits enough. Reaching the TPM needs no administrator and no group:
|
|
the package installs a udev rule tagging `/dev/tpmrm0` with `uaccess`, so the ACL follows whoever is
|
|
logged in at the machine and goes away with them — the same mechanism a webcam uses. What that costs, and
|
|
the one symlink that undoes it, is in the rule's own comments. See
|
|
[ADR 0007](docs/adr/0007-device-key-protection.md) decision 4,
|
|
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 11 and
|
|
[Installing on Linux](#installing-on-linux).
|
|
|
|
Still to do here: Windows signing (the first Windows release is unsigned, and the trigger for buying a
|
|
certificate is the first release aimed at strangers), macOS on Intel, a `v*`-tagged Linux release and a
|
|
signed package repository to serve it from, and Phases 18 and 19 of the manual checks — neither the macOS
|
|
nor the Linux build has been walked through by a person. There is no macOS runner in CI at all. The Linux
|
|
package is verified as far as what it *says* about itself — CI builds it and interrogates its
|
|
dependencies, its file list and its modes, and installs nothing — and the two pieces of Linux behaviour
|
|
that are code rather than packaging do have tests: the update channel's version comparison and digest
|
|
check run in CI, and the TPM store's seal, unseal, wrong-PIN and lockout paths run against a software TPM
|
|
anybody can start with one `docker run`. What no runner covers is `/dev/tpmrm0`, PackageKit and polkit,
|
|
which is what Phase 19 is for.
|
|
- **M5 — multi-provider OIDC**, identity key rotation, per-item content keys.
|
|
|
|
## Licence
|
|
|
|
[MIT](LICENSE).
|