Public Access
Move the keys when a membership changes, not just the flag
Adding somebody to a team granted them nothing readable and removing them
rotated nothing. Both were honest — the interface said so in as many words — and
both left the actual work to a button somebody had to remember to press, on a
machine that happened to hold the key. Adding now wraps every team vault this
machine can open to the new member, and removing revokes their grants and moves
each of those vaults to a fresh key that goes to whoever is left.
The rotation is where the design had to be decided rather than written. A vault
key is per generation and an item carries the generation it was sealed under, so
advancing the vault and withdrawing the old grants would make everything already
stored unreadable to everybody, including whoever pressed the button. So earlier
grants are kept: a member holds one per generation, /me serves them as
PriorKeyWraps, and VaultKeyring holds a key per generation — the newest for
writing, the item's own for reading, chosen per item on every read path. Sharing
issues one grant per generation held, because a recipient handed only the current
key would open the vault to find most of it undecryptable; revocation takes every
generation, because leaving the history behind leaves them able to read
everything written before the rotation.
The bump itself is one server transaction. POST /vaults/{id}/rekey must name
exactly current + 1 and the vault's xmin token makes that binding, so two admins
rotating at once do not both walk away believing they succeeded — the second is
refused and told to read the vault again. The server contributes the moment and
no cryptography: it cannot generate the key, cannot tell that the one it is
handed differs from the old one, and checks that the caller held the old one the
only way it can, by requiring a live grant at the current generation.
What this does not do is re-encrypt what is already stored, and the product says
so rather than the reassuring version: everything written from the rotation
onwards is unreadable to the person who left, and nothing about the past changes.
That half is deferred and is safe to add incrementally precisely because a vault
at mixed generations stays readable. ADR 0010 records the alternatives — revoking
the old grants, chaining each key under its successor, re-sealing every item in
one request against a server that caps a push at 500 operations — and why each
was rejected.
Two things fell out of the change rather than being asked for. The grant listing
would have shown a member once per generation, so it now returns one row per
holder carrying the best key they hold, which is what makes a row below the
vault's generation mean "still owed the new key". And MarkUnreadable gives up the
write target as well as reporting: a client whose vault was rotated elsewhere
would otherwise have gone on sealing items under its superseded key — readable to
its author, unreadable to everybody else, with nothing to show for it.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# 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.
|
||||
|
||||
## What this deliberately does not do
|
||||
|
||||
**It does not re-encrypt what is already stored.** After a rotation the vault's existing items remain
|
||||
sealed under the generations they were written with. The person who left keeps whatever plaintext
|
||||
they already pulled — that is the non-retroactive limit ADR 0001 records and no design here changes
|
||||
it — and, if they kept the old vault key and later obtained ciphertext they had not already
|
||||
downloaded, that ciphertext would still open to them.
|
||||
|
||||
So the guarantee this buys is exact and worth stating in those words: **everything written from the
|
||||
rotation onwards is unreadable to them.** Nothing about the past changes. The product says that
|
||||
rather than the reassuring version, and the honest remediation for a departure is still to rotate the
|
||||
credentials themselves.
|
||||
|
||||
Re-sealing the vault's existing items under the new key is the remaining half and is deferred. It is
|
||||
safe to add incrementally *because* of the decision above: mixed generations are readable, so a pass
|
||||
that migrates items one batch at a time cannot strand anything, and a pass that fails half way leaves
|
||||
a vault that still works. Building it the other way round — bumping the generation only once every
|
||||
item had been re-sealed — would have needed the whole vault to move in one transaction, which is a
|
||||
request-size limit dressed as an architecture.
|
||||
|
||||
## 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.
|
||||
+12
-2
@@ -216,13 +216,23 @@ This is the load-bearing structural choice. Because every wrap protects the *sam
|
||||
|
||||
### Why a per-item DataKey
|
||||
|
||||
1. **Cheap rotation.** Rotating a vault key re-wraps N × 32-byte data keys and never touches
|
||||
content blobs. A 10,000-item vault rotates in a few hundred kilobytes of writes.
|
||||
1. **Cheap rotation.** Re-keying N items re-wraps N × 32-byte data keys and never touches
|
||||
content blobs. A 10,000-item vault re-keys in a few hundred kilobytes of writes.
|
||||
2. **Narrow sharing.** A single item can be re-wrapped to another vault key or user key.
|
||||
3. **Nonce hygiene.** Each key encrypts about one message.
|
||||
4. **Versioning.** A new item version gets a new data key, so prior ciphertext stays
|
||||
independently decryptable for history and undo.
|
||||
|
||||
> **Added 2026-08-03: what a vault key rotation actually does.** Advancing a vault to a new
|
||||
> generation does **not** re-wrap the items already in it. Each item keeps the generation it was
|
||||
> sealed under, in its row and in its AAD, so a rotated vault holds items under two or three keys
|
||||
> at once and every read chooses the key its item names. That is why a member's grants for earlier
|
||||
> generations are kept rather than revoked, why `VaultSummary` serves all of them, and why sharing
|
||||
> issues one grant per generation held: a client holding only the newest key would read the vault's
|
||||
> whole history as tag failures. Re-sealing stored items under the new key is a separate pass and is
|
||||
> not yet built — see [ADR 0010](adr/0010-vault-key-rotation.md) for the guarantee this does and does
|
||||
> not buy.
|
||||
|
||||
Per-item keys wrapped *to individual users* — which is what would make per-item ACLs
|
||||
cryptographic rather than server-enforced — are deferred to M5. The `content_key_id` column
|
||||
exists from the first migration so that lands without a migration. Until then, **an item ACL
|
||||
|
||||
@@ -410,9 +410,11 @@ answerable by anybody willing to create a team first. It simply gets claimed soo
|
||||
| Shared vaults | server + client | A team owns vaults; each is created with the creator's own grant, because a vault with no grant is a container nobody can open. |
|
||||
| Roles | contracts + server | `TeamMemberRole` on the wire, numerically pinned to `DodoSSH.Domain.TeamRole` by a test. Viewer reads, Member writes, Admin and Owner also share and administer. |
|
||||
| Members table | server | `TeamMemberSummary`, and a directory that resolves an exact email to a public key. |
|
||||
| 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. |
|
||||
| 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). |
|
||||
| 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 flag every team vault for rekey, which is a far larger act than the one being asked for. |
|
||||
| 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. |
|
||||
| Renaming and archiving a team | server | `PUT` and `DELETE /api/v1/teams/{id}`. The slug is deliberately not renameable: it is unique only among *live* teams, so a rename could take a slug an archived team still holds and strand it. Archiving soft-deletes the team, every membership and every pending invitation in one transaction — and is refused outright while the team owns any vault. |
|
||||
|
||||
@@ -424,7 +426,7 @@ 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. |
|
||||
| A rekey after a membership change | client | Re-wrapping every item's data key under a fresh vault key, which only a client holding the current one can do. M5. | The vault is flagged `RekeyRequired` and the row says a rotation is owed. |
|
||||
| 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
|
||||
|
||||
Reference in New Issue
Block a user