Merge branch 'main' into the vaults screen, and let it rotate keys too

Main built vault key rotation while this branch was reshaping the screen that
would drive it, so the two met in the same three files. Every other conflict was
textual and resolved by taking both; these are the ones where a decision had to
be made.

**The view model.** Main taught TeamsViewModel three things and this branch had
renamed and rewritten it into VaultsViewModel. All three are ported rather than
dropped, because each is a behaviour rather than wording: adding somebody now
wraps the vault to them on the spot instead of leaving SHARE KEY to be pressed,
removing somebody rotates the vault and hands the new key to whoever is left, and
a share reports how many generations were wrapped. The session calls they reach —
ShareTeamVaultsAsync and RekeyTeamVaultsAsync — are scoped to a membership list
rather than to one vault, and they are called that way here rather than narrowed:
adding somebody is a change to the list, so every vault the list carries is one
they can now fetch. This screen makes lists that carry one vault, so the sentences
name one; where a list carries several, naming them all is the honest report, and
the members section already says the list is shared.

AddMemberAsync ran two lines over the length limit once the sharing was in it, so
the calls behind it moved to AddOrInviteAsync and the three-way refusal to
WhyNobodyCanBeAdded — the command reads as its guards now, which is what it was
before the sharing arrived.

**The tests.** Main's four new cases are ported to the vault-first API, including
the one that matters most: the tampered key log is corrupted *before* the add,
because the add is now a route to a wrap and a test that corrupted it afterwards
would be asserting about the manual route only. SelectingAVault_ListsWhoHoldsAKey
now expects two holders rather than one — main's fake records the creator's own
self-grant, and a key-holder list that omitted it would show the one person who
can certainly open a new vault as somebody who cannot.

**The README.** The limits list is six rather than four or five: main's rotation
entries and this branch's "a vault cannot be deleted" describe different things
and both are true. "The rekey is flagged, never performed" is gone, since it is
now performed, and M3 reads *Done* rather than *Done, except rekey*.

One thing worth writing down that neither side had. An invitation claimed at
sign-in still leaves the key owed, where an add does not: at the moment an
invitation is issued there is no account and no published key to wrap to, and the
claim happens on the invitee's machine, which holds nothing. Manual check 12.1
says so, because a reader who knows adding shares would otherwise read that step
as stale.

1561 tests pass.
This commit is contained in:
2026-08-04 13:58:56 +02:00
51 changed files with 4785 additions and 275 deletions
+73 -29
View File
@@ -186,10 +186,15 @@ signing in again brings it all back; the count in the confirmation is the one th
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.
**Neither a password nor a passphrase has to be typed twice**, and both ways out of that are opt-in. A
password typed to connect is typed once: tick **Remember this password** under the box and it is saved to
your keychain and bound to that host the moment the remote accepts it — or add one outright with **+
PASSWORD** on the Vault screen. And unlock can be a Windows confirmation instead of the passphrase:
Preferences → *This machine***REGISTER** keeps this machine's device key in the TPM, so a later launch
offers **USE WINDOWS HELLO** on the unlock card. A machine with no TPM — and any desktop that is not
Windows — is offered neither button and keeps asking for the passphrase, which Preferences says out loud
rather than leaving you to notice. The passphrase never stops working either way: a declined confirmation
leaves the box exactly where it was.
### Moving files
@@ -297,19 +302,31 @@ rather than only in the ID token or the userinfo response.
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 flag the vault for rekey, and somebody handing a vault over
is usually staying in it.
key grants; removing you would revoke them and rotate the vault, and somebody handing a vault over is
usually staying in it.
Five limits, stated rather than discovered:
Six limits, stated rather than discovered:
- **A vault cannot be deleted.** Nothing in this product removes one, and the server refuses to archive the
membership list behind a vault that still exists — a shared vault is readable *because* of membership, so
archiving it would take the vault away from everybody holding a key, including you, quietly and all at
once. The screen says so where you would otherwise go looking for the button.
- **Removing a member is not retroactive.** It revokes their grants and flags the vault 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.
- **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
@@ -372,8 +389,20 @@ 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 — moving a file *in* from the phone needs the system document picker and is the next piece of
work rather than a thing the screen pretends to do. Hosts and groups are made and corrected here now, from
put in one. So the way in is **ADD FILES**, which is the system document picker: point at a document
wherever it lives and it goes to the directory showing, rather than choosing on the left and pressing an
arrow. What Android hands back is a `content://` URI with no path behind it and no promise of a seek, so
the document is copied into the app's own cache and the copy is what the queue moves — which is what lets
a stopped upload resume from where it stopped. The copy is deleted when the transfer finishes, kept while
it is stopped so RESUME has something to read, and swept at the next launch.
**SAVE FILE is the way back out**, and it is the system's save picker for the same reason: there is nowhere
this application could put a file that you would then be able to open. You choose where it goes before the
transfer starts, the download runs into the cache, and the finished bytes are copied out to the document you
chose. That order has one visible cost, and the screen says it rather than leaving it to be discovered: the
picker creates the file when you dismiss it, so a download that then fails leaves an empty one there. The
alternative is a picker that appears minutes later over whatever you moved on to — and often while the app
is in the background, where Android will not show one at all. Hosts and groups are made and corrected here now, from
a floating + on the Hosts screen, and both editors are cards in the list's own row rather than dialogs, so
the form never covers the thing it is about. The keychain has no editor of its own: SSH keys and buckets are
created on the desktop and sync down, and the phone will delete an item — behind the same counted
@@ -518,10 +547,15 @@ keychain plus a terminal — and the spike that gates all of it.
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.
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
@@ -582,9 +616,9 @@ keychain plus a terminal — and the spike that gates all of it.
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 — shared vaults**, sharing, ACLs. *Done, except rekey.* 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
- **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
@@ -595,20 +629,30 @@ keychain plus a terminal — and the spike that gates all of it.
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.
**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).
**Ownership transfer is here, and it is one write rather than two.** The member you name becomes owner
and you become an admin, in a single transaction — because ownership is sole, so promoting first leaves
the team owned twice and demoting first leaves it owned by nobody, and there is nobody left with the
authority to finish a transfer that stopped in the middle. Nothing else is touched: you keep your vault
key grants, because removing the outgoing owner would revoke them and flag every team vault for rekey,
which is a much larger act than the one being asked for.
- **M4 — hardening and ops**, packaging, self-hosting guide.
- **M5 — multi-provider OIDC**, key rotation, per-item content keys.
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.
- **M5 — multi-provider OIDC**, identity key rotation, per-item content keys.
## Licence