# DodoSSH

A self-hosted, team-oriented SSH client with an end-to-end encrypted vault.

Manage hosts, credentials and keys in a desktop app; sync them across your devices and share
them with teammates through a server you run yourself. **The server stores ciphertext and never
holds a key** — the operator cannot read the credentials it stores.

> Status: early development. See [the milestone plan](#milestones) for what exists today.

## Why

Teams either scatter SSH credentials across individual `~/.ssh` directories with no sharing
story, or pay per-seat for a hosted product that holds their infrastructure credentials.
DodoSSH keeps the convenience of a synced, shareable vault while remaining self-hostable and
zero-knowledge.

## Architecture

| Component | Choice |
| --- | --- |
| Backend | ASP.NET Core on .NET 10, PostgreSQL + EF Core |
| Client | Avalonia (C#): a desktop head for Windows/Linux/macOS and a phone-first Android head, sharing one set of view models; terminal pane is a WebView running xterm.js |
| Auth | OIDC, provider-agnostic (Entra ID, Keycloak, Auth0, Authentik) |
| Vault | End-to-end encrypted; X25519 + Ed25519 + XChaCha20-Poly1305, Argon2id unlock |
| Connections | Client-direct SSH by default, with an optional raw-TCP server relay |

Three consequences worth knowing before you read further:

- **Revocation is not retroactive.** A removed member keeps what they already downloaded. The
  real remediation is rotating the SSH credential, so offboarding is built around a rotation
  checklist rather than a button that implies more than it delivers.
- **No session recording in relay mode.** The relay forwards SSH ciphertext, so it cannot see
  commands. That is the cost of the relay not being able to read your traffic.
- **Locking the vault does not close your shells.** Lock closes the vault and zeroes every key it
  held; a session that authenticated before it keeps running, because the remote host never
  consulted the vault and the credential was already spent. That is deliberate — you lock when you
  walk away from the machine, which is exactly when a long upgrade or transfer is most likely to be
  in flight, and an idle auto-lock that killed it would be worse than the exposure it removed. The
  honest reading is that *locked* describes the vault and not this machine's access to your hosts.
  The unlock screen therefore shows how many shells are still connected, and quitting DodoSSH is
  what ends them.

The reasoning behind each major decision is recorded in [`docs/adr/`](docs/adr/), starting with
[the E2EE trust model](docs/adr/0001-e2ee-trust-model.md).

The desktop client's interface was built from a design covering more product than exists yet — file
transfer, teams, saved snippets, port forwarding. Everything that design asked for and this build has not
got is written down in [`docs/design-import-gaps.md`](docs/design-import-gaps.md), with the layer each
piece would land in and what the interface shows in its place. Nothing was rendered with invented data to
fill a screen.

## Repository layout

```
src/
  DodoSSH.Contracts        DTOs shared with the client — the real API contract
  DodoSSH.Crypto           DSH1 envelope, AAD derivation, the key hierarchy
  DodoSSH.Domain           entities and invariants, no EF
  DodoSSH.Infrastructure   DbContext, configurations, migrations
  DodoSSH.Api              the server
  DodoSSH.Client.Auth      OIDC code+PKCE on a loopback redirect, and the key binding
  DodoSSH.Client.Api       the typed server client, and client-side enrollment
  DodoSSH.Client.Domain    the decrypted item model and the three-way merge — no I/O at all
  DodoSSH.Client.Storage   the local cache: ciphertext mirror, outbox, offline unlock material
  DodoSSH.Client.Sync      the pull/apply/push loop and the conflict policy
  DodoSSH.Client.Session   where a profile lives, unlocking it, and getting one in the first place
  DodoSSH.Client.Ssh       connections, PTY shells, SFTP, host key trust
  DodoSSH.Client.Terminal  the loopback data plane and credit-based flow control
  DodoSSH.Client.Transfer  the transfer queue, part files and resume, and the local file listing
  DodoSSH.Client.ObjectStore  S3-compatible buckets, behind the same interface as SFTP
  DodoSSH.Client.Import    reading ~/.ssh/config, with no I/O of its own
  DodoSSH.Client.Shell     the view models both heads drive, the renderer's files, the palette
  DodoSSH.Client.App       the desktop head: its views, and its Windows integration
  DodoSSH.Client.Android   the phone head: its views, and its Android integration
tests/                     one test project per source project
docs/adr/                  architecture decision records
docs/design-import-gaps.md what the client's design asked for and this build has not got
docs/platform-flags.md     what differs off Windows, and the gotchas that have cost time
docs/manual-checks.md      what no test can reach, and what to look for when checking by hand
docs/android-port.md       the Android head: what was decided, what is built, what is left
```

Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia.
That is the seam that lets the SSH layer, the terminal's flow control and the OIDC flow be tested without a
UI toolkit or a browser engine — which is most of why they are testable at all.

`Shell` is the narrow exception and it earns it: what it takes from Avalonia is `Dispatcher`, the asset
loader and a resource dictionary, none of which imply a window, and what it holds is the shell's state
machine — which two heads have to agree on exactly rather than approximately. The rule's purpose was never
Avalonia-avoidance for its own sake; it was that the layers with the hard logic stay testable, and none of
them are here.

## Building

Requires the .NET SDK pinned in [`global.json`](global.json) (10.0.x).

```bash
dotnet build DodoSSH.slnx
```

```bash
dotnet test DodoSSH.slnx
```

The tests need a Docker daemon. Everything that touches the database, the identity provider or an SSH
server uses Testcontainers rather than a stub or a shared instance, so there is nothing to start first and
nothing to clean up after — but with no daemon those suites fail rather than skip.

## Running it

Three commands, in order. The first is once per machine.

**1. The development dependencies** — PostgreSQL and Keycloak, with the `dodossh` realm imported:

```bash
docker compose -f deploy/docker-compose.dev.yml up -d
```

**2. The server:**

```bash
dotnet run --project src/DodoSSH.Api
```

It applies any pending migrations before it opens its port, so there is no separate schema step and no
window in which a request meets a half-applied schema. Set `Database:AutoMigrate` to `false` where
something else owns the schema — a migrator job, or a database user denied DDL — and the old behaviour
comes back: readiness fails while a migration is pending, and the log says which one. To apply them by
hand, `dotnet-ef` is pinned in `.config/dotnet-tools.json` (`dotnet tool restore` first if you have not):

```bash
dotnet ef database update --project src/DodoSSH.Infrastructure
```

With nothing else configured that targets the compose stack above. Set `DODOSSH_DESIGN_CONNECTION` to
point it at another database.

The server listens on `http://localhost:5233`, serving `/healthz/live`, `/healthz/ready` and — in
Development — `/openapi/v1.json`.

**3. The desktop client:**

```bash
dotnet run --project src/DodoSSH.Client.App
```

In the app, enter `http://localhost:5233` as the server. Your browser opens for sign-in — the realm ships
`alice` / `alice` — then choose a vault passphrase and **write down the recovery code**, which cannot be
skipped and cannot be recovered from the server. You can then add a host and open a shell on it — double-click
it in the sidebar, or select it and press **CONNECT**, which is the same command with the password box beside
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.

Two of M1's known gaps are visible immediately, so they are worth expecting rather than diagnosing: password
authentication asks for the password every time, because nothing in the interface can create a vault
credential yet (they do sync — there is just no editor for one); and unlock asks for the passphrase on every
launch, because no device key is registered.

### Moving files

**FILES** in the nav rail is a two-pane browser: this machine on the left, the host on the right, and a
queue underneath. Choose a host, press **CONNECT**, then select a file in either pane and press the arrow
pointing the way you want it to go.

Two things about it are worth expecting rather than discovering.

**It is a second connection, not a second channel.** SSH itself would allow the SFTP subsystem to open
beside a shell on the transport that is already up; SSH.NET does not offer that — its `SftpClient` owns its
own transport — so pressing CONNECT here authenticates again. The host records a second login, and a host
whose password you type each time will ask for it again on this screen. Host key trust is shared: a
fingerprint approved for a terminal is approved here, and one approved here reaches your other machines with
the next sync.

**Nothing is written at its final name until it is complete.** Every transfer goes to a `.dodossh-part` file
beside its destination and is renamed into place at the end, so an interrupted transfer can never be
mistaken for a finished one — which matters most for what people actually use this for, which is copying a
build artefact onto a server and then running it. A destination that already exists is refused outright
rather than overwritten; the remote pane has **DELETE** and **MKDIR** so that refusal is not a dead end.
DELETE asks first and names the full path, and it carries the strongest warning in the application on
purpose: everything else DodoSSH deletes is a tombstone against a copy the server still holds, and a file on
somebody's host is bytes with nothing behind them.
**RESUME** on a stopped transfer carries on from what the part file already holds.

Resume works within a run of the application and not across a restart, and that limit is deliberate: nothing
records which source wrote a part file, and resuming one on the strength of its name matching is how a
corrupt artefact gets delivered with nothing reporting a failure. A part file found at startup is started
over.

What is not here: transferring a directory, dragging between the panes, and routing a transfer through a
bastion — the last needs jump hosts the connection layer has not got. All three are in
[`docs/design-import-gaps.md`](docs/design-import-gaps.md).

### Working as a team

**TEAMS** in the nav rail creates a team, adds members and shares vaults. One distinction runs through the
whole screen and is worth having before you use it.

**Adding somebody to a team and giving them a key are two different acts, and only the first is something
the server can do.** Adding a member changes what the server will *serve* them: the team's vaults appear in
their list immediately. It cannot make those vaults readable, because a vault key is sealed to each member's
public key and this server never holds one — so until somebody presses **SHARE KEY** from a machine that has
the key, their vault sits in the list saying it is waiting for one. That is not a rough edge to be smoothed
over later; it is what "the operator cannot read the credentials it stores" costs, and the screen says so
rather than implying the server handed anything out.

Sharing verifies before it wraps. The client reads the server's append-only key log, checks its hash chain
from the first entry, and refuses unless the key the directory just offered appears in that log unchanged.
That converts a key substitution by the server from invisible into visible — a substituted key has to be
published in a log every other client also reads. **It does not prove the key is the right person's.**
Compare the fingerprint with them over something this server does not carry; that is the only step that
closes it, and the success message says so every time.

Three limits, stated rather than discovered:

- **Removing a member is not retroactive.** It revokes their grants and flags the team's vaults for rekey,
  and blocks future reads. Everything they already pulled is 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.
- **The rekey is flagged, never performed.** See the milestone note above.
- **Host key trust stays in your personal vault.** A pin approved for a team's host is recorded and used
  from your own vault, not the team's, so a teammate cannot pre-approve a fingerprint that your client will
  then trust silently for a host you defined. The cost is that each member approves a team host's key once
  on each of their machines. Team vaults' pins are still *listed* on the Vault screen, so you can see what
  has been trusted.

Items are filed into one vault at a time. When more than one vault is writable, the host and vault editors
show a picker; it defaults to your personal vault and never moves on its own, because an item put in a team
vault is visible to everybody in that team and moving it back means deleting and retyping.

### The Android head

`src/DodoSSH.Client.Android` is a phone-first head that shares every view model with the desktop one — the
keychain and a terminal, which is the scope [`docs/android-port.md`](docs/android-port.md) decided on and
the reasoning behind it. Sign in, unlock, browse hosts, open a shell, and read the keychain; the two
host-key decisions and the counted delete confirmations are there too, and none of them were softened to
fit 360dp.

What it does **not** have is file transfer — deliberately, since scoped storage means there is no local
pane to put beside the remote one — and the four list screens the desktop grew last (pins, snippets, logs,
teams), whose view models are already shared and which are additive rather than structural. Importing an
`~/.ssh/config` has no meaning on a phone at all.

It is deliberately **not** in `DodoSSH.slnx`. Putting it there would make the `android` workload and a full
Android SDK a prerequisite of `dotnet build DodoSSH.slnx` for everybody; it has its own CI job instead, which
builds *and packages* it, because the two failures it is most exposed to — a native library with no Android
ABI, and an assembly that resolves but will not dex — are both invisible to a plain compile.

Building it needs the workload and **API 36 specifically**:

```bash
dotnet workload install android
```

```bash
dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj
```

API 36 is not a preference. `Avalonia.Controls.WebView` ships only a `net10.0-android36.0` assembly, so
anything lower cannot resolve it and the head loses its terminal. The floor is API 28, which is where
`BiometricPrompt` and StrongBox-backed keys exist without an AndroidX shim.

Four things about it are worth expecting rather than discovering.

**Signing in does not use the desktop's loopback redirect, and must not.** On a shared device any other
application can bind a loopback port and race for the authorization code — the attack RFC 8252 §8.3 names.
The phone registers a redirect with the system instead and is handed the response as an intent. Everything
above that — PKCE, the state check, discovery, the token exchange, the key binding — is the same code the
desktop runs, because the only thing that varies is where the response arrives.

The redirect is a private-use scheme (`dev.dodotech.dodossh:`) rather than an Android App Link, and the
limit is worth knowing: another app can declare the same scheme, and Android will offer a chooser rather
than refuse. PKCE is what makes an intercepted code useless. An App Link closes it properly and costs an
`assetlinks.json` on your own server's domain.

**A fingerprint releases the device key, and re-enrolling a fingerprint destroys it.** The key is generated
with `setInvalidatedByBiometricEnrollment`, which is what stops somebody who can add their own fingerprint to
an unlocked phone from inheriting the vault. The cost is that adding a finger legitimately means typing the
passphrase again and re-registering — which the unlock screen treats as ordinary, because it is.

**A notification appears while a shell is open.** Android stops backgrounded processes, and the desktop
client's promise that locking the vault does not close your shells is only true here behind a foreground
service. The notification is the price of that promise; it goes when the last shell does.

**The recovery code screen blocks screenshots.** `FLAG_SECURE` is raised for that one state and lowered
again afterwards, so the screen's own claim is true and a shell is still screenshotable. It stops the
accident worth stopping — the only copy of an unrecoverable code landing in a cloud photo library, or in the
recent-apps thumbnail — and stops nothing determined, since a second phone photographs a screen perfectly
well.

**Nothing has been run on a device.** It compiles, links, packages, and carries the right native libraries
for arm64 — that is verified, and CI verifies it on every change. Everything about its runtime behaviour is
not, and `docs/android-port.md` says which claims those are.

### End-to-end verification

One suite runs against a real server rather than a stub. It needs a Docker daemon and nothing else, so it
is part of the ordinary test run:

```bash
dotnet test tests/DodoSSH.SystemTests
```

It brings up PostgreSQL, Keycloak and an OpenSSH server in containers, applies the committed migrations,
starts the API as a child process out of its own build output, and then drives the real client: sign in
through Keycloak, enroll, unlock, create an SSH key and a host bound to it, sync them, open a shell on the
`sshd` and approve its host key at the real first-contact refusal, then read all three back on a second
simulated machine and unlock again with no network. Roughly 25 seconds once the images are pulled.

What makes it worth its weight is that it consumes the artefacts that ship — the realm file from
`deploy/keycloak`, the EF migrations, the API's own `appsettings` — rather than a fixture written to match
them. On its first run it found a loopback redirect URI the realm registered in a form Keycloak rejects,
and a JSON configuration gap that made the whole sync surface unreachable from the real client while every
other test passed. Both are the same class of bug: two sides of a stub agreeing with each other about
something the specification never said.

The one value it cannot take from a committed file is `Oidc:Authority`, since the container's port is
assigned at start. Everything that authority points at is still the real realm.

Development is Windows-first, but **the full suite now runs on Linux too**, and CI runs it there on
every change. Getting there cost three fixes rather than none, and each was a real difference instead
of a test being fussy: the local file pane built its roots bar from every mount the kernel holds, one
assertion recognised the profile directory only by its Windows capitalisation, and the layout harness
pinned a COM error that only Windows raises. macOS is still unverified.

Anything known or suspected to differ is tracked in
[`docs/platform-flags.md`](docs/platform-flags.md), along with the deployment gotchas that have already
cost time once. Read it before assuming something works off-Windows.

Android has been audited and scoped, but not started:
[`docs/android-port.md`](docs/android-port.md) records what ports as it stands (most of the core), what
does not (most of the interface), the decisions taken about what an Android client would be — phone-first,
keychain plus a terminal — and the spike that gates all of it.

### Conventions the build enforces

- Warnings are errors. `dotnet format --verify-no-changes` gates CI.
- Package versions are centralised in `Directory.Packages.props`; `packages.lock.json` is
  committed and CI restores in locked mode.
- [`BannedSymbols.txt`](BannedSymbols.txt) bans `DateTime.UtcNow` (use `TimeProvider`),
  `Guid.NewGuid` (use `CreateVersion7`), sync-over-async, MD5/SHA1 and PBKDF2.
- Public members of `DodoSSH.Contracts` must be declared in `PublicAPI.Unshipped.txt`, so a
  contract change is a build error rather than a client-side surprise.

## Milestones

- **M0 — foundation.** Repo structure, build conventions, CI, ADRs. *Done.*
- **M1 — vertical slice.** OIDC login → enroll → create a host → open a shell.
  *Server done:* the DSH1 crypto core, the data model, sync push/pull for hosts, `/me`, and
  enrollment with the identity-provider key binding.
  *Client done:* the key hierarchy, the OIDC flow with the key binding, SSH connections with host key
  trust, the terminal data plane, the encrypted local cache with the sync client — offline unlock, an
  outbox and a field-level three-way merge, conflict matrix green — and an Avalonia shell that is
  vault-backed: server URL → browser sign-in → enroll → unlock → host list → terminal. The shell's *state
  machine* is covered by tests against an in-memory server, so the states that matter most (the recovery
  code that cannot be skipped, the unlock that needs no network) are checked rather than remembered.

  Its *layout* is not covered by anything, and that gap has already cost a shipped defect: the setup and
  unlock screens were layered over the terminal's WebView, which on Windows is a native child window that
  cannot be covered, so they rendered sliced with their buttons unclickable. No test in this repository
  loads a `.axaml` file, and a headless one could not have caught this — there is no native window in
  headless, so it would have rendered perfectly and confirmed the wrong belief. Screens get looked at, or
  they are unverified.
  *Verified end to end:* `tests/DodoSSH.SystemTests` drives the whole slice against a real Keycloak, a
  real API, a real PostgreSQL and a real `sshd` — sign-in, the identity-provider key binding, enrollment,
  offline unlock, a host and an SSH key through the vault to a second machine, an interactive shell, and the
  host key approved at that shell's prompt reaching the second machine as well. See
  [End-to-end verification](#end-to-end-verification).

  Known gaps in the client, stated rather than implied by the interface: nothing in the interface can create
  a vault credential yet, so password authentication still asks for the password each time — SSH keys *are*
  editable, and binding one to a host is the way to connect without typing anything; and no device key is
  registered, so the passphrase is needed on every launch until the OS keystore is wired.

  Host key trust *is* in the vault, which is what makes trust-on-first-use worth having: a fingerprint
  approved on one machine is approved on all of them and survives a restart, and the server cannot drop a
  pin to force a fresh first-use decision without the item visibly going missing. A changed host key stays a
  hard refusal with no way past it; withdrawing a pin is a separate, deliberate act in the host's editor.

  Binding a key introduced the first payload schema version bump, and it is worth knowing how it behaves:
  a host is written at the *lowest* schema version that can represent it, so only hosts that actually bind
  a key are written at version 2 and become read-only on an older build. Hosts that do not are still
  written at version 1, byte-identically to before the field existed — which is what keeps upgrading one
  machine from making a team's whole vault uneditable everywhere else.
- **M2 — full personal vault**, robust sync, relay. *File transfer done:* an SFTP session, a two-pane file
  browser with a real remote listing — names, sizes, modification times and `drwxr-xr-x` permission bits —
  and a queue that moves one file at a time with progress, throughput and resume. See
  [Moving files](#moving-files) for the two things about it worth knowing before you use it, both of which
  are consequences rather than choices.

  *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 groups are flat, because a
  parent pointer merged field by field lets two offline clients build a cycle that nothing can repair.

  Inserting a snippet types it at the prompt and stops. Pressing Enter is a per-snippet decision, off by
  default, and the reason is worth stating: a terminal is one input stream with no notion of being at a
  prompt — the remote may be in an editor, or at a password prompt with the echo off — so this client cannot
  honestly say "run this command", only "type this into whatever is there".

  *Logs done:* what has been connected to, and what has been changed in the keychain. Both are synced,
  encrypted items rather than local files, because the point of them is auditing a shared vault — a log only
  one machine can read is a diagnostic, not an audit trail. Two consequences are stated rather than implied.
  The connection log records the host's name, the address dialled, when, for how long and by which
  account on which machine — but **not** the SSH username, which is a detail of the host and is in the host's
  own logs. The keychain log records the **names** of the fields an edit touched and never their contents.

  What that costs is in [ADR 0001](docs/adr/0001-e2ee-trust-model.md): the server still cannot read a single
  field, but one row per connection with a server-side timestamp tells it your connection rate and the hours
  you work. Retention bounds it — ninety days or five thousand entries per kind, whichever bites first.

  *Buckets done:* S3-compatible object storage is a second kind of remote on the Files screen, beside a host.
  Prefixes are directories, objects are files, and transfers go the same way through the same queue. The
  bucket, its endpoint and its keys are a keychain item like any other, encrypted end to end — which matters
  more than usual here, because for anybody self-hosting MinIO or Ceph the endpoint is an address on their
  own network.

  Three things a bucket cannot do are refused with a reason rather than approximated: there are no
  directories, an interrupted **upload** starts again rather than resuming (an object cannot be written from
  the middle), and a rename is a copy and a delete rather than one atomic operation. Downloads do resume — a
  ranged GET is part of the protocol, which is the one place a bucket beats SFTP.
- **M3 — teams**, sharing, ACLs. *Done, except rekey.* Teams with roles, a public-key directory, the
  append-only key log served for clients to verify against, team-owned vaults, and vault key grants
  wrapped by a client and stored opaquely by the server. `VaultAccessService` now resolves team
  membership to permissions, so a viewer may pull and may not push; the desktop client reads and syncs
  every vault it holds a key for, and a real TEAMS screen replaces the placeholder. See
  [Working as a team](#working-as-a-team) for the one distinction the whole design rests on, and the limits worth
  knowing before you rely on it; the reasoning is in
  [ADR 0009](docs/adr/0009-team-access-model.md).

  **What is deliberately not here: the rekey itself.** Removing a member revokes their grants and flags
  every team vault `RekeyRequired`, and nothing acts on that flag. A rekey re-wraps every item's data key
  under a fresh vault key and can only be performed by a client that holds the current one; that is M5's
  key rotation. Until it lands the flag is what the interface reads to say a rotation is owed, which is
  more honest than a button that only appears to do it. Ownership transfer is absent for the same kind of
  reason — the owner cannot be removed or demoted, because nothing can appoint a replacement.
- **M4 — hardening and ops**, packaging, self-hosting guide.
- **M5 — multi-provider OIDC**, key rotation, per-item content keys.

## Licence

[MIT](LICENSE).
