# 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, and the keys it names when asked
  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
scripts/                   release-windows.ps1 — builds, packs and publishes the Windows client
                           release-macos.sh   — the same, signed and notarized, on a Mac
build/macos/               the entitlements and Info.plist template the macOS bundle 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.

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.

**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).

### 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. 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.

**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 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

- 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.

  *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).

  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, Linux packaging, and Phase 18 of the
  manual checks — the macOS build has never actually run, because there is no macOS runner in CI and
  everything above is verified only as far as the bundle.
- **M5 — multi-provider OIDC**, identity key rotation, per-item content keys.

## Licence

[MIT](LICENSE).
