Blog

Removing someone without leaking the next photo

Making a new key is the easy part of removing someone from an album. The hard part is the time before that key exists, and remembering it is owed after the evidence has been deleted.

Alice runs an album called Picnic with Bob and Carol. She removes Carol. From that moment one promise has to hold: every photo added to Picnic afterwards is encrypted under a key Carol never had.

The cryptographic answer is well known: make a new album key and give it to everyone except Carol. In Miuchio that is a new epoch. Making the key is the short part of this story. Keeping the promise around it took four fixes, and the third is the reason for this post: deleting some rows made an unfinished key change look finished.

Removal is two events

On the server, removal is one transaction. Carol's membership is revoked, and from then on the server refuses her album requests. The new key cannot be made there, because the server never holds album keys. Alice's phone has to fetch each remaining member's public keys, run a handshake with each, sign the result and upload it. With a good connection that takes a moment. If Alice's phone is off, it takes as long as the phone stays off. Today only the admin can make the new key; letting any member make it is what I'm working on next.

Between those two events, the album's current key is still the one Carol holds. At first, the only upload rule was that a photo must be encrypted for the current epoch, and during this gap that rule passes. A photo Bob added then would be readable by Carol if its ciphertext ever reached her: through a leak, a stolen backup or a dishonest server.

Fix 1: freeze uploads, inside the same lock

The first rule is simple to state: while a new epoch is owed, the server refuses uploads to the album. It is easy to get wrong, because a check followed by a separate write is a race. If the check passes, then Alice removes Carol, then the upload is written, the photo slips through under the old key.

So the check and the write share one transaction, and that transaction starts by locking the album's row. Removing a member, committing an epoch and inviting someone begin the same way:

SELECT id FROM albums WHERE id = $1 FOR UPDATE
Removals, leaves, new epochs, invites and both upload checks all begin with this statement, so for any one album they run one at a time.

Whichever transaction commits first wins. If the removal wins, the upload is refused with a reason the app understands, and the app pauses that album's upload queue instead of treating it as an error.

Fix 2: check the other end of the upload too

An upload has three steps. The phone reserves a record, sends the encrypted bytes straight to storage, then confirms, which makes the photo visible to the album. The freeze was checked in the first step only.

The middle step can take minutes on a slow connection. A photo reserved just before Carol's removal could be confirmed just after it, and be published under a key she holds. Now confirm repeats both checks under the same lock: the photo's epoch must still be current, and no new epoch may be owed.

1. Reserverecord and both wraps2. Sendciphertext to storage3. Confirmphoto becomes visibleUnder the album lock:epoch is currentno new epoch owedUnder the album lock:epoch is currentno new epoch owedStorage enforces thedeclared size and SHA-256If refused: wrap the keys againunder the new epoch and resend
The same checks run at both ends, because a removal can commit while the bytes are in flight.

When confirm refuses, nothing has to be encrypted again. A photo is encrypted under its own random key, bound to its media ID. Only that key and the thumbnail's key are wrapped under the album key, as two 61 byte wraps. So the phone keeps the encrypted photo exactly as it is. Once it has installed the new epoch, it wraps the two keys again under the new album key, reserves again with the same media ID, sends the same bytes and confirms.

Two details keep that retry safe. The server recognises a new reservation for the same media ID with identical bytes and keeps the storage names it already issued. And every reservation gets a fresh ID that confirm must quote, so a confirm left over from an earlier attempt can never publish a later one.

Fix 3: the evidence disappears

At first, whether a new epoch was owed was computed from the data:

owed  =  (members holding a wrap for the current epoch)  !=  (active members)

After Carol's removal, the current epoch still has a wrap for her, and she is no longer active, so the two lists differ. The rule read the missing epoch straight off the database, with no extra state to keep in sync. I liked it for that.

Then I built account deletion. When an account is deleted, the server removes every trace of its memberships, including its wraps. Now picture Carol deleting her account while Picnic still owes its new epoch:

Right after Carol is removedHold a wrap for the current epochAliceBobCarolActive membersAliceBobLists differ: a new epoch is owedAfter Carol deletes her accountHold a wrap for the current epochAliceBoberasedActive membersAliceBobLists match: looks settledrotation_required = TRUE in both, until epoch 1 is committedCarol's phone holds the current key in both columns.
Deleting Carol's rows removed the only evidence that her key was still in use.

Her wrap for the current epoch is erased, and the two lists match again. The freeze lifts, uploads resume under the old key, and Carol's phone still holds that key. Nobody attacked anything. One feature deleted the evidence that another feature's safety rule was reading.

The fix is to store the obligation instead of deriving it. Every removal sets a flag in the same transaction that revokes the member:

UPDATE albums SET rotation_required = TRUE WHERE id = $1
Set by removal, by leaving, and by the account deletion job. Cleared only by committing a new epoch.

The server accepts a new epoch only if the admin's signed recipient list matches the live members exactly, so clearing the flag proves the new key reached everyone who should have it. The old comparison still runs as a second reason to freeze, but it can no longer lift the freeze on its own. A regression test, TestRotationRequired_SurvivesErasedWraps, erases the departed member's wraps directly and checks that uploads are still refused.

Fix 4: make sure the new epoch happens

A freeze is only acceptable if it ends. For now only the admin creates epochs, so Alice's phone has to notice that one is owed. It checks:

  • when the app starts,
  • when it reconnects,
  • when it hears that someone was removed or left,
  • and when one of its own uploads is refused for this reason.

Only one attempt runs per album at a time, and failed attempts are retried after a short pause. If another attempt wins the race, that counts as success.

Ending the freeze on the server is still not enough for Bob. His queued photos need the new key before they can be wrapped under it. An earlier version resumed his queue when the realtime event announcing the new epoch arrived. Realtime events are hints and can be missed, and when one was, his photos stayed paused until he restarted the app. Now a phone resumes an album's uploads only after it has installed the new epoch itself, whoever created it, and it fetches the current epoch from the server whenever an album stops owing one.

Who gets the new key

Creating the epoch means fetching each remaining member's public keys from the server. A removal is a predictable moment, so it is a good one for a dishonest server to slip in a key of its own. Alice's phone therefore wraps the new key only to identity keys it has already pinned for those members. If a member has no pin, the change stops before a new key is even generated. If a fetched key does not match its pin, the change stops before anything is sent.

What removal still cannot do

  • It cannot take everything back. Carol's app deletes the album when it learns she was removed, but screenshots, copies saved outside the app, and a modified app that ignores the removal are out of reach.
  • Old links expire on their own schedule. Download links issued to Carol before her removal keep working for up to 30 minutes, and only for photos she could already open.
  • It depends on the admin, for now. If Alice never comes back online, Picnic stays frozen. I prefer an album that pauses to one that quietly keeps using a key a removed person holds, and letting any member make the new key is what I'm working on next.

The exact rules are on Albums and epochs.

What I took from it

  • Revoking access and distributing a new key are separate events. Design for the time between them.
  • A check and the write it protects belong in one transaction, under a lock that every competing change also takes.
  • A long operation needs the check at both ends, not only at the start.
  • Store security obligations. Do not infer them from data that another feature may delete.