Public Access
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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user