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
+6
View File
@@ -67,6 +67,12 @@ One thing is deliberately **not** built, and it is a refusal rather than an omis
- **The rekey itself.** Only a client holding the current vault key can re-wrap every item's data key
under a new one. The server records that a rotation is owed and the interface reports it. M5.
> **Superseded 2026-08-03 by [ADR 0010](0010-vault-key-rotation.md).** Rotation now ships, and it
> turned out to divide differently than this paragraph assumed: advancing the generation is one
> server transaction and is not the same act as re-wrapping the items, which is still outstanding.
> Removing a member rotates the vaults the removing client can open and hands the new key to whoever
> is left.
Two smaller choices, recorded because the alternative was written down first and rejected:
- **No `v_user_vault_permission` view.** ADR-adjacent notes and the old `VaultAccessService` remark
+144
View File
@@ -0,0 +1,144 @@
# ADR 0010 — Rotation advances a generation; the keys before it are kept
- Status: accepted
- Date: 2026-08-03
- Builds on: [ADR 0001](0001-e2ee-trust-model.md), [ADR 0009](0009-team-access-model.md)
## Context
ADR 0009 shipped removal as "revoke the grants and flag the vault", and named the missing half
plainly: only a client holding the current vault key can produce the next one, so the server could
record that a rotation was owed and nothing more. Nothing acted on the flag. In practice that meant
removing somebody from a team left every vault they could read encrypted under the key they had, for
ever — the interface said a rotation was owed and no button existed to perform one.
Two things had to be decided before that flag could be acted on, and they are not independent.
**When does the generation change?** A vault key is per vault *per generation* (`docs/crypto.md` §3),
and a grant names the generation it opens. If two admins rotate at the same time, both wrap a key,
both issue grants, and the vault ends up with two claimed "current" keys and a set of members split
between them — half of whom cannot read what the other half writes, with nothing to point at as the
cause.
**What happens to everything already stored?** An item carries the generation it was sealed under, in
its own row and in its AAD. A rotation that advanced the generation and left the old grants behind
would make every item written before it unreadable to everybody, including the person who rotated. A
rotation that re-encrypted every item would avoid that — and is a different, much larger operation:
`crypto.md` §3 puts it at N × 32 bytes of re-wrapped data keys, which is cheap in bytes and is still
a write to every row of a vault, in batches, against a server that caps a push at 500 operations and
8 MB, with the connection/activity logs alone reaching five thousand entries per kind.
## Decision
**The rotation is the generation bump, and it is one server transaction. Grants for earlier
generations are kept.**
`POST /api/v1/vaults/{id}/rekey` takes the next generation and the new key sealed to the caller. In
one transaction the vault's `key_generation` advances, the caller's grant for it is inserted, and the
rekey flag is cleared. The request must name exactly `current + 1`, and the vault's `xmin`
concurrency token makes that check binding rather than advisory — the second of two simultaneous
rotations is refused and told to read the vault again. The server contributes the *moment*, which is
the one part of a rotation a client cannot decide for itself; it contributes no cryptography, cannot
tell that the key it is handed differs from the old one, and cannot tell whether the caller held the
old one. That last part is checked the only way it can be: the caller must hold a live grant at the
current generation, which is a row rather than a proof.
Everything that follows from keeping the old grants:
- **A member holds one grant per generation, and `VaultSummary` serves all of them.** The current
wrap stays where it was; the rest arrive as `PriorKeyWraps`, oldest first. `VaultKeyring` holds a
key per generation, hands out the newest for writing and the item's own for reading. Every read
path picks its key from the payload's `keyGeneration` rather than from the vault's.
- **Sharing hands over the history.** `ShareVaultAsync` issues a grant for every generation the
sharing client holds, oldest first. Somebody added after a rotation who was given only the newest
key would open the vault to a list of items that will not decrypt — which reads as corruption, not
as a missing grant. The server accordingly accepts a grant for any generation the vault has
reached, and refuses one for a generation ahead of it: nothing is sealed under that, and accepting
it would let a client move the vault forward outside the transaction that is allowed to.
- **Revocation takes every generation.** Removing a member, and `RevokeGrantAsync`, revoke all of a
recipient's grants rather than the current one. Leaving the history would leave them able to read
everything written before the rotation, which is exactly what the rotation was for.
- **A member between the rotation and their re-wrap can read and cannot write.** They hold the
history and no current key, so the vault lists as unreadable and writes refuse. Writing under a
superseded key would produce items nobody else could open, and the author's own keyring — which
still holds that key — would show no sign of it.
**Removing a member rotates automatically.** The teams screen removes the member, then rotates every
team vault the machine can currently open and wraps each new key to the members who remain. Adding a
member is the mirror image: every team vault this machine can open is wrapped to them as part of the
add. Both report per vault, including what they could not do — a vault whose key this machine does
not hold is skipped and stays flagged, because somebody else has to finish it.
## The second half: re-sealing what is already stored
> **Added 2026-08-04.** This was deferred when the decision above was taken, and is now built. The
> reasoning that made it safe to defer is what made it cheap to add, so it is recorded here rather
> than in an ADR of its own.
A rotation on its own re-keys the vault and not its contents, which leaves one gap: somebody who left
with a copy of the old key could still open old ciphertext they later got hold of. `VaultResealer`
closes it by walking the vault and rewriting each item under the current key, as an ordinary upsert
against the version the server holds.
Four properties, each of which is a decision:
- **It never decodes the plaintext.** An item is opened and the *same bytes* are sealed again under a
fresh data key. No codec, no merge, no schema version — so an item written by a newer client
survives untouched, where re-encoding it through this build's codec would silently drop the fields
this build has no concept of. It is also why one pass covers every item type, including types added
after it was written.
- **It is resumable, and needs no transaction.** Each item is one upsert, so a pass that dies half way
leaves a vault at mixed generations — which is a state that reads perfectly well, because that is
precisely what the decision above bought. Running it again picks up what is left.
- **A conflict is counted, not merged.** The pass changes no content, so there is nothing to merge:
an item somebody else wrote meanwhile is left at their version and re-sealed on the next pass.
- **A queued local edit is left alone, and re-sealed on the way out instead.** Rewriting it here would
overwrite the user's unpushed work with the version the server holds. Instead `SyncEngine` re-seals
a queued payload whose generation is stale as it dispatches it, and writes the revision back to the
outbox first so a retry sends the same bytes. That closes the one hole a pass over *stored* items
cannot see: a change made before the rotation and pushed after it would otherwise put a brand-new
item into the vault under the key the departed member holds.
The pass runs as the last step of a rotation, after a sync — a mirror that is behind produces a batch
of conflicts rather than a re-sealed vault. The interface reports which of the two guarantees was
reached, because they are different: a vault fully re-sealed is closed to the person who left, and one
where items were left behind is closed only to what happens next.
## What this still does not do
**It does not reach what they already pulled.** The person who left keeps whatever plaintext is on
their machine — that is the non-retroactive limit ADR 0001 records and no design here changes it. The
honest remediation for a departure is still to rotate the credentials themselves, and the product says
so rather than the reassuring version.
## Alternatives rejected
- **Revoke the old grants on rotation.** Tidier, and it makes the grant list say exactly one thing
per member. It also makes every item written before the rotation unreadable to everybody, which is
data loss performed by a security feature.
- **Chain the keys: store each old key sealed under its successor.** One wrap per rotation instead of
one grant per member per generation, and new members get the history for free. It needs a new table,
a new AAD purpose, and a recursive unwrap on the read path — and it makes the vault's whole history
reachable from the current key, which is a strictly larger blast radius than a set of grants that
can be revoked one at a time.
- **Rotate atomically with every item re-sealed, in one request.** The safest shape on paper and the
one `crypto.md` implies. It caps rotation at the push limits — 500 operations and 8 MB — which a
vault with a year of connection log in it exceeds, and the failure mode is a vault that can never
be rotated at all.
- **Let the server generate the new key.** It would make rotation a single call and would end the
product: a server that can produce a vault key can read the vault.
## Consequences
`vault_key_grant` grows by one row per member per rotation. The unique index is already per
`(vault, generation, recipient)`, so this needed no migration; the rows are 110-byte seals and a vault
rotated monthly for a decade with ten members holds twelve hundred of them.
The sharing graph gains a dimension the operator can read: which generation each member holds, and so
which of them have been re-wrapped since the last rotation. That is the same class of metadata ADR
0009 already records as visible, and it is the same fact the sharing screen shows the members
themselves.
A client that never comes back holds keys to generations that no longer receive writes, which is the
same exposure as any copy of a vault key on a machine that has been lost — bounded by the fact that
the server will not serve them anything, and unbounded in the way every non-retroactive revocation is.
+125
View File
@@ -0,0 +1,125 @@
# ADR 0011 — Distributing the Android client, and who holds the release key
- Status: accepted
- Date: 2026-08-03
- Builds on: [ADR 0001](0001-e2ee-trust-model.md)
- Settles: the second open question in [`docs/android-port.md`](../android-port.md#still-open)
## Context
[ADR 0001](0001-e2ee-trust-model.md) ends on the hole it cannot close with cryptography: **an operator
who wants the secrets attacks the client, not the crypto**, and what that costs is *release signing with
a key not held by the server*, and eventually reproducible builds. Until now that sentence had nothing
to bind to. The desktop head is not packaged at all — packaging is M4 — and the Android head is a debug
artefact: CI runs `-t:SignAndroidPackage` with no keystore, so it is signed with the debug key .NET for
Android falls back to when `AndroidKeyStore` is false, and that APK is a build check rather than
something anyone installs.
The first release changes that, and it does so **irreversibly**, which is why this is decided here
rather than at upload time. Two Android facts make it a one-way door:
- **An installed app can only be updated by a package signed with the same key.** The signing key is
the app's identity for its whole life; changing it means every existing user uninstalls first, losing
their local cache and re-enrolling. (v3 signature rotation exists, but the lineage has to be created
*before* it is needed, by the key it is rotating away from.)
- **A new app on Google Play must ship as an App Bundle, which means Play App Signing**, so Google
generates and holds the key that signs what users install; the developer holds an upload key only.
There is no un-enrolling. For an app that already exists outside Play, the only way to keep one
package id across both channels is to *hand Google the existing key*.
So "publish on Play" and "hold our own key" are not two settings. They are two package identities, and
the first release picks one.
The third party changes but the shape does not: **whoever can sign an update can ship one person a
build that copies the passphrase.** The vault's encryption is irrelevant to that attack — the client is
where the plaintext is, by construction (ADR 0001, `Connect` cannot be a security boundary). So this ADR
is about *which* parties are in that position, not about removing them, and there are three candidates:
the deployment operator, DodoTech, and Google.
## Decision
**DodoTech holds the release key, the deployment never serves the client, and Play is a separate
decision that has not been taken.**
1. **One release key, held by the project, kept offline, and never in CI.** Release signing is a
deliberate manual step on a machine that is not a runner. CI keeps doing exactly what it does now —
packaging with the debug key to catch link-time failures — and must never gain a keystore secret or
an `AndroidKeyStore=true`. A signing key in CI is a key held by whoever can push a workflow file,
which for a public repository is a wider set than it looks.
2. **The APK is published on the project's own release page, and a DodoSSH deployment never distributes
it.** This is the refusal that carries the security content, and it is the one a self-hosted product
gets wrong by default: a "download the app" link on your own server is convenient, obvious, and hands
the client binary to the exact party ADR 0001 models as the adversary. The operator may tell people
where to get it. They may not be the place it comes from.
The same rule reaches the update path. A version check pointed at the deployment lets the operator
pin a chosen user to a known-vulnerable build by withholding the answer — a weaker attack than
signing one, and available without any key at all. If an update check is ever added it points at the
project's domain, and the first release simply has none: the release page is the channel and the
README says so.
3. **Play App Signing is not entered, and cannot be entered by accident.** Not because Google is a worse
custodian than DodoTech — on the mechanics it is a better one, since the key lives in Google's
infrastructure rather than on a laptop, and the reach and auto-update story is not close. It is
declined because of what it costs *this* product specifically: the buyers named in ADR 0001 are teams
who refuse to put infrastructure credentials in a SaaS, and telling them the client that holds their
plaintext is signed by a key the vendor cannot see is the same answer they already rejected, one
layer down. A targeted signed build compelled by a lawful order or produced from a compromised
console account is the archetype of the attack ADR 0001 calls the largest practical hole.
Deferring is cheap and reversing is not, so the default falls the deferrable way. Revisiting is a
second ADR, and it has two honest exits: hand Google the existing key and keep one identity, or take
a distinct package id and accept two apps. Both are worse decisions to discover than to take.
4. **Reproducible builds are the goal that makes all of the above matter less, and they are not
achievable today.** A build a third party can reproduce from source turns the signing key from a
trusted authority into a convenience — anyone can check that the published APK is the published
source. .NET for Android is not there: dex output, AOT images and archive timestamps are not
bit-reproducible across machines in practice. It stays the standing goal ADR 0001 names, recorded
here as the thing that would let point 3 be reconsidered on the merits rather than on custody.
5. **F-Droid is not a channel.** Its build server compiles from source and signs with its own key, which
would be a genuinely better transparency story — but it has no support for a .NET workload plus an
Android SDK toolchain, and this head needs both. Not refused; unavailable.
## Consequences
**The reach cost is real and should not be talked down.** Installing means enabling installation from
the browser or file manager, per source, on Android 8 and later — a permission the platform frames as
dangerous, correctly. There is no discovery, no automatic update, and no Play channel for a corporate
MDM to deploy from, which for a product sold to teams is the sharpest edge of this decision. What
partially answers it is that this client is installed by people who already run their own identity
provider and their own vault server; sideloading is not the strangest thing they will do that week.
**The key becomes a single point of failure with no recovery.** Losing it means existing installs can
never be updated again — not a bad update, *no* update — and the only way out is a new package id and a
manual migration. It is therefore backed up offline in more than one place, and a v3 rotation lineage is
created at the first release rather than at the first emergency, because a lineage can only be signed by
the key it replaces.
**The attack ADR 0001 names is narrowed, not removed.** DodoTech can still ship one user a malicious
build. What changes is that the deployment operator — the party the threat model is actually about, and
the one with a motive to read their own team's credentials — cannot, and that a compelled or breached
third-party store is not in the path either. That is the whole of what this decision buys, and it is
worth stating at that size rather than larger.
**M4's desktop packaging inherits rule 2 and not the rest.** Windows and macOS have no equivalent of
Play App Signing in the mandatory sense: Authenticode and Developer ID both leave the private key with
the developer, and Apple's notarization is a scan rather than a signature over the shipped binary. The
custody question is therefore easy there; the "not served by the deployment" rule is the part that
carries over, and it carries over unchanged.
## Rejected
- **Play as the primary channel, sideloading as the fallback.** This is the arrangement most Android
products land on, and it does not survive contact with rule 2's reasoning: it is the same shape —
the binary that holds the plaintext arriving through a party who can be compelled — with a larger and
better-resourced party in the middle. Better mechanics, same class.
- **Shipping the APK from the DodoSSH server it will talk to**, so a new phone gets the client from the
deployment it is enrolling against. Genuinely the nicest onboarding available, and it makes the
operator the distributor of the client that holds their team's credentials. Refused outright, and
named here because it will be proposed again.
- **A release key held by CI so tagging cuts a release.** The convenience is the point of CI and the
key is the point of this ADR; where they collide the key wins. Signing an artefact is one command, run
rarely, by a person.