Files
DodoSSH/docs/adr/0010-vault-key-rotation.md
T
jaap-jan 5d447da532 Take a rotated vault's contents onto the new key as well
Rotating a vault re-keyed the vault and not its contents, which was the deal
struck last time: everything already stored stayed sealed under the generation it
was written with, every remaining member kept the older keys, and the guarantee
was narrowed to "nothing written from now on". That left one gap worth closing —
somebody who walked off with the old key could still open old ciphertext they
later got hold of — and the reason it was safe to defer is the reason it was
cheap to add. A vault at mixed generations reads perfectly well, so the pass that
moves items across can stop half way and be run again.

VaultResealer walks the vault and rewrites each item as an ordinary upsert
against the version the server holds. It never decodes the plaintext: an item is
opened and the same bytes are sealed again under a fresh data key, so an item
written by a newer client crosses a rotation untouched rather than being
re-encoded through this build's codec and quietly losing the fields this build
has no concept of. It also means nothing in the pass knows what an item is, which
is why one loop covers every type including the ones added after it. A conflict
is counted and skipped rather than merged — there is nothing to merge, since no
content changes — and the next pass picks the item up at the version the other
client left.

The half that a pass over stored items cannot see is a change queued before the
rotation and pushed after it, which would put a brand-new item into the vault
under the key the person who just left still holds. So the push path re-seals a
stale payload as it dispatches it, writing the revision back to the outbox first
so that a retry sends the same bytes rather than a fresh envelope. Between the
two, nothing reaches the server under a superseded generation at all. Queued
items are therefore deliberately left alone by the pass: rewriting one there
would overwrite the user's unpushed work with the version the server holds, which
is the one thing a re-keying pass must never do.

Removal runs it last, after a sync — a mirror that is behind produces a batch of
conflicts instead of a re-sealed vault — and the status line distinguishes the two
guarantees, because they are not the same: a vault fully re-sealed is closed to
the person who left, and one with items outstanding is closed only to what
happens next.

Six tests, and three mutations run against them: making the re-seal return the
payload unchanged fails five of the six, making the push path skip re-sealing
fails the queued-edit test and only that one, and counting conflicts as applied
fails the write-elsewhere test. One of the six was wrong before it was right — it
modelled a third-party write by re-pushing an existing payload at a bumped
version, which no real client would do, and it took reading the AAD to see that
the test was lying rather than the code.
2026-08-04 10:18:14 +02:00

145 lines
9.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.