Public Access
Merge branch 'claude/team-key-share-rotate-4b2619'
Two conflicts, and the second is worth recording. main's M4 bullet gained the Android signing decision while this branch rewrote the M5 line either side of it; both are kept. The other is an ADR number collision: two sessions each took 0010, one for vault key rotation and one for Android distribution, and both are now on main. ADR numbers are identifiers — "see ADR 0010" appears in code comments as well as in prose — so leaving two would make every such reference ambiguous. The rotation ADR landed first and is referenced from crypto.md, the gaps document, ADR 0009 and the sync code; the Android one is referenced from README and android-port.md. So the later and cheaper one moves: 0010-android-distribution.md is now ADR 0011, with its title and both references updated. Nothing about either decision changes.
This commit is contained in:
@@ -69,25 +69,47 @@ member is the mirror image: every team vault this machine can open is wrapped to
|
||||
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
|
||||
## The second half: re-sealing what is already stored
|
||||
|
||||
**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.
|
||||
> **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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ADR 0010 — Distributing the Android client, and who holds the release key
|
||||
# ADR 0011 — Distributing the Android client, and who holds the release key
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-03
|
||||
@@ -618,7 +618,7 @@ worth reading is which way each went and why, not that the list is empty.
|
||||
API 28 and 29 still cost one branch each in `BiometricGate`, because allowed-authenticator lists arrived
|
||||
in 30.
|
||||
- ~~**How it is distributed, and what that does to the supply-chain story.**~~ **✅ Settled in
|
||||
[ADR 0010](adr/0010-android-distribution.md): the project holds the release key, the deployment never
|
||||
[ADR 0011](adr/0011-android-distribution.md): the project holds the release key, the deployment never
|
||||
serves the client, and Play is deferred.** The question was whether Play App Signing — Google generating
|
||||
and holding the release key — is a change to the security property ADR 0001 documents. It is, and the ADR
|
||||
takes it as a one-way door rather than a setting: a new Play app must use App Bundles and therefore Play
|
||||
|
||||
+7
-3
@@ -229,9 +229,13 @@ This is the load-bearing structural choice. Because every wrap protects the *sam
|
||||
> 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.
|
||||
> whole history as tag failures.
|
||||
>
|
||||
> Moving those items onto the new key is a **separate pass** (`VaultResealer`), run last and resumable
|
||||
> because a vault at mixed generations is readable. It opens each item and seals the same plaintext
|
||||
> again under a fresh data key — the envelope is re-made rather than only the wrap, because the AAD
|
||||
> binds `keyGeneration` into both. The plaintext is never decoded, so an item written by a newer client
|
||||
> crosses a rotation untouched. See [ADR 0010](adr/0010-vault-key-rotation.md).
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user