Public Access
docs/android-port.md was an audit of work not started; it now says what is built. Three of its statements needed correcting rather than extending, and they are marked where they sit: the Android version question is settled and was never as open as it looked, because Avalonia.Controls.WebView ships only a net10.0-android36.0 assembly and nothing lower can resolve it; cleartext to loopback has to be permitted explicitly, which the audit missed entirely; and the spike produced a structural change it did not anticipate, in DodoSSH.Client.Shell. A CI job of its own, because the head is deliberately not in DodoSSH.slnx and a project outside the solution is a project nobody notices breaking. It packages as well as builds: a native library with no Android ABI and an assembly that will not dex are both invisible to a compile, and both are exactly what this head is exposed to. The README says plainly that signing in is not built, that a fingerprint re-enrolment destroys the device key, that a notification appears while a shell is open, and that none of it has run on a device.
453 lines
29 KiB
Markdown
453 lines
29 KiB
Markdown
# DodoSSH
|
|
|
|
A self-hosted, team-oriented SSH client with an end-to-end encrypted vault.
|
|
|
|
Manage hosts, credentials and keys in a desktop app; sync them across your devices and share
|
|
them with teammates through a server you run yourself. **The server stores ciphertext and never
|
|
holds a key** — the operator cannot read the credentials it stores.
|
|
|
|
> Status: early development. See [the milestone plan](#milestones) for what exists today.
|
|
|
|
## Why
|
|
|
|
Teams either scatter SSH credentials across individual `~/.ssh` directories with no sharing
|
|
story, or pay per-seat for a hosted product that holds their infrastructure credentials.
|
|
DodoSSH keeps the convenience of a synced, shareable vault while remaining self-hostable and
|
|
zero-knowledge.
|
|
|
|
## Architecture
|
|
|
|
| Component | Choice |
|
|
| --- | --- |
|
|
| Backend | ASP.NET Core on .NET 10, PostgreSQL + EF Core |
|
|
| Client | Avalonia (C#): a desktop head for Windows/Linux/macOS and a phone-first Android head, sharing one set of view models; terminal pane is a WebView running xterm.js |
|
|
| Auth | OIDC, provider-agnostic (Entra ID, Keycloak, Auth0, Authentik) |
|
|
| Vault | End-to-end encrypted; X25519 + Ed25519 + XChaCha20-Poly1305, Argon2id unlock |
|
|
| Connections | Client-direct SSH by default, with an optional raw-TCP server relay |
|
|
|
|
Three consequences worth knowing before you read further:
|
|
|
|
- **Revocation is not retroactive.** A removed member keeps what they already downloaded. The
|
|
real remediation is rotating the SSH credential, so offboarding is built around a rotation
|
|
checklist rather than a button that implies more than it delivers.
|
|
- **No session recording in relay mode.** The relay forwards SSH ciphertext, so it cannot see
|
|
commands. That is the cost of the relay not being able to read your traffic.
|
|
- **Locking the vault does not close your shells.** Lock closes the vault and zeroes every key it
|
|
held; a session that authenticated before it keeps running, because the remote host never
|
|
consulted the vault and the credential was already spent. That is deliberate — you lock when you
|
|
walk away from the machine, which is exactly when a long upgrade or transfer is most likely to be
|
|
in flight, and an idle auto-lock that killed it would be worse than the exposure it removed. The
|
|
honest reading is that *locked* describes the vault and not this machine's access to your hosts.
|
|
The unlock screen therefore shows how many shells are still connected, and quitting DodoSSH is
|
|
what ends them.
|
|
|
|
The reasoning behind each major decision is recorded in [`docs/adr/`](docs/adr/), starting with
|
|
[the E2EE trust model](docs/adr/0001-e2ee-trust-model.md).
|
|
|
|
The desktop client's interface was built from a design covering more product than exists yet — file
|
|
transfer, teams, saved snippets, port forwarding. Everything that design asked for and this build has not
|
|
got is written down in [`docs/design-import-gaps.md`](docs/design-import-gaps.md), with the layer each
|
|
piece would land in and what the interface shows in its place. Nothing was rendered with invented data to
|
|
fill a screen.
|
|
|
|
## Repository layout
|
|
|
|
```
|
|
src/
|
|
DodoSSH.Contracts DTOs shared with the client — the real API contract
|
|
DodoSSH.Crypto DSH1 envelope, AAD derivation, the key hierarchy
|
|
DodoSSH.Domain entities and invariants, no EF
|
|
DodoSSH.Infrastructure DbContext, configurations, migrations
|
|
DodoSSH.Api the server
|
|
DodoSSH.Client.Auth OIDC code+PKCE on a loopback redirect, and the key binding
|
|
DodoSSH.Client.Api the typed server client, and client-side enrollment
|
|
DodoSSH.Client.Domain the decrypted item model and the three-way merge — no I/O at all
|
|
DodoSSH.Client.Storage the local cache: ciphertext mirror, outbox, offline unlock material
|
|
DodoSSH.Client.Sync the pull/apply/push loop and the conflict policy
|
|
DodoSSH.Client.Session where a profile lives, unlocking it, and getting one in the first place
|
|
DodoSSH.Client.Ssh connections, PTY shells, SFTP, host key trust
|
|
DodoSSH.Client.Terminal the loopback data plane and credit-based flow control
|
|
DodoSSH.Client.Transfer the transfer queue, part files and resume, and the local file listing
|
|
DodoSSH.Client.ObjectStore S3-compatible buckets, behind the same interface as SFTP
|
|
DodoSSH.Client.Import reading ~/.ssh/config, with no I/O of its own
|
|
DodoSSH.Client.Shell the view models both heads drive, the renderer's files, the palette
|
|
DodoSSH.Client.App the desktop head: its views, and its Windows integration
|
|
DodoSSH.Client.Android the phone head: its views, and its Android integration
|
|
tests/ one test project per source project
|
|
docs/adr/ architecture decision records
|
|
docs/design-import-gaps.md what the client's design asked for and this build has not got
|
|
docs/platform-flags.md what differs off Windows, and the gotchas that have cost time
|
|
docs/manual-checks.md what no test can reach, and what to look for when checking by hand
|
|
docs/android-port.md the Android head: what was decided, what is built, what is left
|
|
```
|
|
|
|
Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia.
|
|
That is the seam that lets the SSH layer, the terminal's flow control and the OIDC flow be tested without a
|
|
UI toolkit or a browser engine — which is most of why they are testable at all.
|
|
|
|
`Shell` is the narrow exception and it earns it: what it takes from Avalonia is `Dispatcher`, the asset
|
|
loader and a resource dictionary, none of which imply a window, and what it holds is the shell's state
|
|
machine — which two heads have to agree on exactly rather than approximately. The rule's purpose was never
|
|
Avalonia-avoidance for its own sake; it was that the layers with the hard logic stay testable, and none of
|
|
them are here.
|
|
|
|
## Building
|
|
|
|
Requires the .NET SDK pinned in [`global.json`](global.json) (10.0.x).
|
|
|
|
```bash
|
|
dotnet build DodoSSH.slnx
|
|
```
|
|
|
|
```bash
|
|
dotnet test DodoSSH.slnx
|
|
```
|
|
|
|
The tests need a Docker daemon. Everything that touches the database, the identity provider or an SSH
|
|
server uses Testcontainers rather than a stub or a shared instance, so there is nothing to start first and
|
|
nothing to clean up after — but with no daemon those suites fail rather than skip.
|
|
|
|
## Running it
|
|
|
|
Four commands, in order. The first two are 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 schema.** The API never migrates anything: it fails readiness while a migration is pending, and
|
|
says which one. `dotnet-ef` is pinned in `.config/dotnet-tools.json`, so run `dotnet tool restore` first if
|
|
you have not:
|
|
|
|
```bash
|
|
dotnet ef database update --project src/DodoSSH.Infrastructure
|
|
```
|
|
|
|
With nothing else configured this targets the compose stack above. Set `DODOSSH_DESIGN_CONNECTION` to point
|
|
it at another database.
|
|
|
|
**3. The server:**
|
|
|
|
```bash
|
|
dotnet run --project src/DodoSSH.Api
|
|
```
|
|
|
|
It listens on `http://localhost:5233`, serving `/healthz/live`, `/healthz/ready` and — in
|
|
Development — `/openapi/v1.json`.
|
|
|
|
**4. 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. It is
|
|
**partly built**: what exists is the unlock screen, the platform integration under it, and a shell that
|
|
names each state it has not built yet rather than showing an empty screen. What it is for, what was decided
|
|
and what is left are in [`docs/android-port.md`](docs/android-port.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.
|
|
|
|
Three things about it are worth expecting rather than discovering.
|
|
|
|
**Signing in is not built yet, and that is the one real gap.** The desktop client receives the authorization
|
|
response on a loopback `TcpListener`; reusing that on a phone would be a security regression rather than a
|
|
shortcut, because on a shared device any other app can bind a loopback port — the attack RFC 8252 §8.3 names.
|
|
It needs an app link instead. A phone enrolled from the desktop client unlocks here perfectly well, because
|
|
**unlocking needs no network at all**, so this blocks first-run rather than the product.
|
|
|
|
**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.
|
|
|
|
**Nothing has been run on a device.** It compiles, links, packages, and carries the right native libraries
|
|
for arm64 — that is verified. 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 and testing are currently **Windows-only**. Anything known or suspected to differ on
|
|
Linux and macOS 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).
|