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.
This commit is contained in:
2026-08-04 10:18:14 +02:00
parent d5b1a73182
commit 5d447da532
12 changed files with 923 additions and 68 deletions
+1 -1
View File
@@ -413,6 +413,7 @@ answerable by anybody willing to create a team first. It simply gets claimed soo
| Sharing an item | client | `VaultSession.ShareVaultAsync`: verify the recipient's key against the key log, wrap, sign, record. The server stores the wrap and the signature and can check neither. One grant per generation the sharing client holds, so a recipient can read a rotated vault's history and not only what happens next. |
| Adding a member shares the team's vaults | client | Adding somebody wraps every team vault the adding machine can open to them, as part of the add rather than as a button to remember. Membership and a key are still two acts on two machines; the client just performs both. A vault this machine holds no key to is skipped and named. |
| Removing a member rotates the vaults | server + client | `POST /api/v1/vaults/{id}/rekey` advances the generation and records the caller's new grant in one transaction — the server contributes the moment and no cryptography. The client then wraps the new key to the members who remain. Grants for earlier generations are kept, or the vault's stored items would become unreadable to everybody. See [ADR 0010](adr/0010-vault-key-rotation.md). |
| Re-sealing a rotated vault's stored items | client | `VaultResealer`, the last step of a rotation: every item is opened and the same plaintext sealed again under the new key, in batches, against the version the server holds. The plaintext is never decoded, so an item written by a newer client crosses a rotation untouched. Resumable, because a vault at mixed generations is readable — a pass that stops half way leaves a working vault and is re-run. A change queued before the rotation is re-sealed by the push path instead, so nothing reaches the server under a superseded key at all. |
| Pending invites, and withdrawing one | server | A `team_invitation` row per (team, address), listed beside the members it is about and withdrawable until it is taken up. It becomes a membership when an account with that address signs in — **and only if the access token asserts `email_verified`**, because membership is authorisation and an invitation anybody could take by naming somebody else's address is a way in. Fourteen days, because an address that is reassigned would otherwise carry a standing offer to whoever holds the job next. |
| Ownership transfer | server | `POST /api/v1/teams/{id}/owner`, owner only. One transaction: the named member becomes owner and the outgoing owner becomes an admin. Not two role changes — ownership is sole, so promoting first leaves the team owned twice and demoting first leaves it owned by nobody. The outgoing owner is demoted rather than removed, because removing them would revoke their vault key grants and rotate every team vault, which is a far larger act than the one being asked for. |
| `LAST ACTIVE` | server | Real, and coarse on purpose. `UserAccount.LastSeenAtUtc` is now refreshed on ordinary authenticated requests, at most once per account per hour: writing it per request would put an UPDATE on the hot path of every authenticated call and start losing races on `user_account`'s own concurrency token. So the column answers "this week or not", which is the granularity the question is actually asked at, and is shown coarsely rather than to the minute. |
@@ -426,7 +427,6 @@ answerable by anybody willing to create a team first. It simply gets claimed soo
| The invitation mail, and **resend** | server | An outbound mail path: an SMTP configuration, a template, a bounce story and a deliverability problem, none of which this server has. | **Nothing is sent, and the interface says so.** An invitation is a standing instruction rather than a message — the next account to sign in with that address joins the team — so there is no token, no link, and nothing to resend. Telling somebody to sign in is done over a channel this server does not carry. A link nobody can deliver would be worse than no link. |
| Archiving a team that owns vaults | — | Nothing that would be safe. A team vault resolves through membership, so archiving would take those vaults away from everybody holding a key, silently, including the caller — and nothing in this product deletes a vault, so there is no sequence of calls that turns the refusal into a success. | Refused, with `team-not-empty` and a count of the vaults in the way. A stated limit rather than a coming feature, for the reason the SFTP layer refuses a recursive delete: a refusal is visible and a quiet removal is not. |
| `SSO · OIDC · okta.dodotech.dev` | server | Per-team SSO. Authentication is one global JWT scheme bound to one authority. | Omitted. |
| Re-sealing a rotated vault's stored items | client | Re-wrapping every item's data key under the new vault key, in batches, which only a client holding both keys can do. M5. | The rotation itself ships — see the row above. Existing items keep the generation they were sealed under and stay readable, because every member keeps the keys they were granted. What is outstanding is closing the gap where a departed member's copy of the old key would still open old ciphertext they later obtained. |
> **The trap this document warned about is still a trap.** `GET /api/v1/meta` advertises
> `features: ["teams"]` *unconditionally* (`MetaEndpoints.cs`). It was meaningless when nothing implemented