GroupThink
Mechanism: Revoking a Device
22 min read
Adding a device to an account is a decision about the future, and the account gets to make it. Removing one is a claim about a machine that is no longer yours to instruct — and a mark in a database column is not an instruction. What exists today is a server-authoritative flag plus a cooperative self-logout, which does the whole job for the ordinary cases and leaves a specific, checkable set of things open. This is published before that gap closes, on the view that naming an edge precisely is worth more than waiting to describe it in the past tense.
Prerequisites: Joining and Leaving for the roster this operates on and why it is server-authoritative, and Mechanism: Absorbing a New Device for the direction that works cleanly.
1. A claim about a machine you do not control
Joining and Leaving ended on an asymmetry worth restating, because everything below is a consequence of it.
Adding a device is constructive. To become a member, a machine must acquire things it does not have: an enrollment grant minted by a device already in the account, a snapshot exported by a sibling, a link in the device-authorization chain signed by a device that was already in the previous roster. Every step needs cooperation from someone who is already inside. A failed add is a non-event — nothing happened, and nothing has to be undone.
Removing a device is destructive, and destructive operations have targets. The target
here already holds: a complete SwiftData replica of your messages, posts, calls, notebook
and memory log; the libsignal double-ratchet sessions for every conversation you have; the
account signing seed, from which InnerMAC.siblingChannelKey(accountSeed:) derives the
channel key that defines the same-account sibling channel; and a bearer token that auth-svc
issued for 30 days (Utc::now() + chrono::Duration::days(30)) and has no code path to take
back. None of that is on a server. UPDATE identity_records SET revoked_at = now() reaches
none of it.
What a removal can actually change is what other parties answer when they are asked. So the strength of a removal is exactly the number of places that ask, times how promptly they ask — and that is an open set which grows every time someone adds a feature.
One piece of housekeeping before the walkthrough. The mechanism in sections 2 and 3 is shipped and works. The gaps in sections 4 through 7 are open in the code as of writing, and section 8 is work owed rather than a description of something built.
2. One column, and why it is a mark rather than a delete
The whole server-side mechanism is migration 0011_device_revocation.sql, and it is one
line:
ALTER TABLE identity_records ADD COLUMN IF NOT EXISTS revoked_at TIMESTAMPTZ;
The interesting content is the comment above it, which states three reasons for marking the row rather than deleting it: the device (a) drops out of the sibling list, (b) cannot be silently resurrected by a re-register of the same pubkey, and (c) can self-detect and re-onboard with a fresh identity.
Reason (b) is the one worth deriving, because a reader's first instinct is that a removed
device should have its row deleted. Follow what happens if it does. register_identity
inserts with ON CONFLICT (pubkey) DO NOTHING and reports the conflict as a 409, taking the
user_id from the caller's session. Delete the row, and the removed device — which still
holds that keypair — re-registers the same pubkey, the insert succeeds because there is
nothing to conflict with, and it is back on the roster with no trace that it ever left.
Keep the row and mark it, and the re-registration collides with a record that is still
there.
That is the same lesson as Deleting Things That Stay Deleted, in a completely different subsystem: absence carries no evidence, so you store the removal instead of storing nothing. The retained row is a tombstone, and reason (c) is only possible because the tombstone survives — a removed device asking "am I still in the account?" needs a row to read the answer off.
Two handlers write the column, both session-authed, both idempotent:
// remove_sibling
UPDATE identity_records SET revoked_at = now()
WHERE user_id = $1 AND pubkey = $2 AND revoked_at IS NULL
$1 comes from the session, never from the request body, so a caller can only revoke
devices on its own account. The handler returns 204 No Content whatever the row count, so
the response never distinguishes "removed it" from "that pubkey was not yours" — the
affected count goes to the log, not to the caller. revoke_all_siblings is the same
statement without the pubkey clause, used on account recreation: the recreating device calls
it while it still holds the old session, so every device on the abandoned account is
marked before the new one is created.
3. How a removed device finds out
A tombstone nobody reads is a comment. The read is GET /v1/sync/self-status/:pubkey_hex,
and its two design choices are both defensible.
It is public — no session. The handler's comment gives the reasoning: the pubkey is
the identifier, and revocation status is not sensitive. A device that has been removed may
well have a session that is broken or absent, so requiring one would make the endpoint
useless in exactly the case it exists for. The response is two booleans:
{ "registered": bool, "revoked": bool }, where an absent row answers
registered: false (a hard delete, as opposed to a revocation).
It is polled by the subject, not pushed to it. checkSelfRevoked() in SocialServices
runs at the end of bootstrap() and again from onForeground(), guarded on
currentSession != nil, isRegistered so only a fully-enrolled device asks. On
status.revoked, three things happen in order: resetForRecreate(), then
userDefaults.set(false, forKey: "pixie.onboarded"), which is the flag ContentView reads,
so the UI swaps to the onboarding flow on the next render.
resetForRecreate() is worth reading for what it keeps as much as what it drops. It wipes
the device identity keys (preserving the phone-ownership certificate, which is bound to the
phone number rather than the account), clears the saved session and the registration flags,
and deletes MessageRecord, PostRecord, CallRecord, OutboxItem, LibSignalBlob and
RowVersion. Its own comment states the scope: drop identity-tied local data; keep
Contacts + agent memory. Because the keys are wiped, re-onboarding mints a fresh
envelope keypair, which registers without colliding with the tombstoned one — closing the
loop opened in section 2.
For the cases most people mean when they say "remove a device", this is the entire job. A phone you sold will run the check on its next launch under the new owner and take itself out. A laptop you left at a friend's house does the same. Account recreation revokes the whole roster in one call so no orphan lingers. None of these situations contain an adversary; the mechanism's job is to make the honest case converge, and it does.
4. Cooperative is not a synonym for eventual
The word doing the work in section 3 is cooperative. Two conditions have to hold, and
both live in code the owner no longer controls: the removed device has to poll, and it has
to honour the answer. Nothing outside the device enforces either. A build that never calls
checkSelfRevoked — a modified client, an older version, a process that never
foregrounds — keeps every capability it had.
The interesting consequence is not that such a device keeps working. It is what the fleet's own filter does to it. Watch the driver's-seat election from both sides.
The remaining devices ask GET /v1/sync/siblings, which filters revoked_at IS NULL, so
the removed pubkey is gone from rankedOldestFirst. The election in
Who Acts is index-based over that list, so the removed device stops being
"higher priority" than anyone, and the fleet re-elects a driver among the devices that
remain. Exactly right.
Now the removed device runs the same code. Its siblings() call returns the account's
remaining devices and, because its own row is revoked, not itself. reconcileSeat opens
with:
guard let myIdx = rankedOldestFirst.firstIndex(of: myPubkey) else {
holdsSeat = true
yielding = false
return
}
Unknown to the roster means act solo, so the agent on a brand-new or offline device never stalls. It is a good default that produces the wrong outcome here: the removed device concludes it is the sole device on the account and takes the seat. The fleet does not split into "one driver and one ex-device" — it splits into two devices that each believe they are driving, one of which is holding a full copy of your life and running autonomous work against it.
One more degradation belongs in this section, because it weakens even the cooperative path.
siblingRoster() falls back to the cached selfAdvertisedDeviceSet when the live fetch
fails — deliberately, so a transient outage degrades to "use what we knew" rather than
"pretend we're alone". The cache still contains the removed device. So a remaining sibling
that cannot reach identity-svc keeps fanning sync_delta frames at a device it would
otherwise have dropped, until its next successful fetch.
5. Nothing asks who is calling
Here is the structural fact underneath all of it, and it is short enough to verify yourself. Ten backend services define an authentication context. All ten are the same struct:
pub struct AuthContext {
pub user_id: Uuid,
}
identity-svc's require_session middleware pulls the bearer token, asks auth-svc to validate
it, and inserts that struct into the request extensions. auth-svc's sessions table is
(session_token, user_id, issued_at, expires_at) — there is no device column, nothing
deletes rows from it, and the revoke_others flag on the recovery flow deletes credentials
(passkeys), not sessions.
So a handler cannot ask whether the calling device is revoked. Not "does not" — cannot. The request carries an authenticated account, and the question is about a device. The information is not in the room. Four consequences follow directly, and each is checkable against one function:
A revoked device can remove the device that removed it. remove_sibling scopes by
auth.user_id, and a revoked device's session still resolves to that user id. The operation
is symmetric between a device that is in the account and one that is not.
A revoked device can re-enroll under a new pubkey. register_identity takes user_id
from the session and inserts. The tombstone refuses the same pubkey; nothing refuses a
freshly minted one. This is at least not silent — the new row appears in the account's
device list, which is the owner's detection surface — but the tombstone stops a resurrection,
not a re-entry.
credential-svc keeps minting sender certificates. post_sender_cert has a revocation
check, and it is the wrong granularity: is_user_revoked looks the caller up in
user_revocations, which trust-svc writes to ban an entire account. There is no device
term. A revoked device with a live session mints a certificate binding its own
sender_pubkey to the account's user_id_hash, and relay-svc's admission gate checks TTL, a
rate-limit token, and a certificate revocation list keyed on cert_id — nothing that could
notice.
And with that certificate, it can ask a sibling for a fresh snapshot.
SyncService.handleRequest gates on one predicate — isSameAccount(senderUserIDHash) — then
exports the whole payload, seals it, uploads it, and offers it back to the replyPubkey
named in the request. The same-account gate is the right one for the threat it was built
against (a stranger impersonating a sibling); it does not distinguish a sibling from a
former sibling. This is the consequence that undercuts the comfortable framing. Revocation is
supposed to give you the device's future. Until this gate learns about the roster, a
removed device can pull a current copy of everything, not merely keep the stale one it
walked out with.
6. The same query, forty lines apart
identity-svc answers two superficially similar questions in two very different ways.
get_siblings is auth-bound: which devices are mine, derived from the session, filtering
revoked_at IS NULL. get_user_pubkeys is a public reverse lookup: which devices belong to
this account id. It exists because group fan-out needs it — group-svc returns a group's
membership as account user_ids, and the sender resolves each to device pubkeys so it can
ship one sealed 1:1 envelope per device.
For a long time, one of these filtered revoked_at and the other did not. They sit forty
lines apart in the same file. The consequence was precise: a device removed in Settings
dropped out of its own account's sibling traffic and kept receiving group traffic — sent by
people whose devices had made the server call that returns the removed pubkey, and had no
reason to question the answer. The revocation held for the owner's own fleet and silently did
not hold for anybody else's sends.
That filter has since landed, and the source comment above get_user_pubkeys now records the
history rather than hiding it: this is a fan-out source: every pubkey it returns gets a
sealed envelope. The pair is more instructive than either query alone, because the fix was
applied to one and not the other for reasons no design document contains. get_siblings was
written as part of the revocation feature and the flag was on the author's mind;
get_user_pubkeys predated it and belonged to a different subsystem. A check that must be
repeated at every call site will be missed at whichever call site the author is not looking
at, and the miss is invisible because both queries return a plausible answer.
The class is not closed by that one fix. Three other public readers still serve a revoked
device's row: get_identity, get_user_id — which subscription-svc resolves through for the
relay entitlement check — and fetch_bundle, which hands out the prekey bundle a peer needs
to open a libsignal session, alongside claim_key_package for MLS. Any peer holding a
removed device's pubkey can still establish a session with it.
Below all of this sits a limit no filter reaches. The relay's subscribe path takes a list of
subjects on the socket's query string and has no session at all — by design, since the relay
must not know who is behind a subject. Pair subjects are derived from
(channelKey, devicePubkey, epoch), and the removed device holds every input, so nothing
stops it subscribing. What stops delivery is that senders no longer publish there.
Revocation is enforced at senders, and can only ever be enforced at senders.
7. What your friends' phones do about it
The contact edge is where the design's privacy commitments and its revocation story pull against each other, and it is worth being exact about the cost.
Your friend's app never asks a server which devices you own. recipientDeviceSet reads only
local storage, and PeerDevice's header comment says why: a server reverse-lookup by pubkey
would let any sender enumerate a target's devices and break sealed-sender unlinkability. So
your contacts learn your device set exactly one way — from you, in a sealed device_list
advertisement over the encrypted channel.
Three details decide how quickly a removal reaches them.
refreshSelfDeviceState recomputes the set as siblings() ∪ {self}, caches it, and calls
pushDeviceListToContacts only when the CSV changed. It runs at bootstrap and on
transport resume — not from the removal handler. removeDevice posts to identity-svc and
refreshes the Settings list; the advertisement rides the next launch or foreground.
applyDeviceList upserts the advertised devices and tombstones the ones the update dropped
(revoked = true, so fan-out stops) — but never the primary pubkey, which is the threading
anchor for the conversation. If the device you removed is the one your friend originally
handshook, that rule alone will not drop it.
chainAuthorizedFilter is what saves that case, for contacts who have one. A contact holding
a device-authorization chain that verified under your pinned account root and bound to the
transparency log intersects the fan-out set against the chain's authorized roster and
honours an empty intersection rather than rescuing to the primary. A contact with no bound
chain — first contact, a legacy peer, or a publish that has not landed — keeps the full set,
primary included.
8. What closing this actually costs
Three pieces of work, and they differ by an order of magnitude in cost. Naming which is which is the useful part.
Roster filtering at every fan-out site is cheap and never finished. It is one WHERE
clause per query, but it is a discipline rather than a mechanism, and section 6 is the
evidence. The structural version costs slightly more and holds: make the default read path
exclude revoked rows — a view, or a single accessor every handler goes through — so seeing a
revoked device requires opting in, and a new call site inherits the safe answer by
forgetting rather than by remembering.
A revocation check on the authenticated caller belongs in the middleware, and cannot go
there yet. require_session is exactly the right layer: one function, every write endpoint,
no per-handler discipline. Written today it would be four lines. It is unimplementable
because the middleware resolves a user and the question is about a device, which makes it
strictly dependent on the third item.
Device-bound sessions are a migration, and the only genuinely expensive piece. sessions
gains a device column; every path that issues a session — passkey assertion, grant redemption,
approve-on-device — has to carry the device pubkey and prove possession of it, or a caller
names someone else's device and the binding is decorative. All ten AuthContext
structs gain a field, and the services that read them ship together with auth-svc. Then
remove_sibling has to actually invalidate: a cross-service call that deletes the removed
device's session rows, which is a capability auth-svc does not have today, since nothing ever
deletes a session. Note one honest cost on the privacy side: binding sessions to devices puts
an account→device linkage in auth-svc. It is not a new fact in the system — identity-svc
holds precisely that mapping — but it is that fact in a second place, and this design usually
pays to avoid duplicating exactly this shape.
There is a fourth item that no amount of server work supplies. The sibling channel key is derived from the account signing seed, and that seed is replicated to every device in the account. Removing a device does not change it, so the removed device remains cryptographically inside the same-account channel — it can still derive its siblings' subjects, and its own. A removal that genuinely changed the channel would have to rotate the account seed and have every remaining device adopt the new one, then publish a new chain version so contacts rebind. That is a rekey, and it is the same operation an MLS group performs on a removal commit — see Algorithm: Who Gets to Put You in a Group for what that buys and what it does not.
9. The part no fix reaches
Suppose all of section 8 ships. Sessions are device-bound and die with the roster row, the middleware refuses a revoked caller, every fan-out site filters, and the account seed rotates on removal. What have you got?
You have stopped the device from receiving anything new, from asking for anything new, and from proving anything about itself. You have not touched the replica. Every message, note, memory entry and contact that was on that disk is still there, in plaintext, and the reason it is there is not an oversight — it is the entire purpose of the machinery in Staying in Sync. A system whose premise is that your devices hold your life locally has, as its exact dual, the property that a device you eject leaves holding it.
So the guarantee is one-sided, and the honest phrasing is the one this series has used before: you can revoke a device's future; you can never revoke its past. The rekey in section 8 is not a way around this. It is the precise statement of it — rekeying is what makes traffic after the removal undecryptable to the removed member, and it says nothing at all about traffic before. That is post-compromise security, and it is the same boundary MLS draws when a Commit removes a member from a group: the removal commit rekeys the epoch, so the removed member's keys stop decrypting from that point forward, and every message they already read stays read.
Which suggests the measure to apply to any device-removal feature, including this one: not whether the device was removed, but which future traffic it is excluded from, by whom, and after how long a delay. Answer that and you have described the mechanism. Say "the device was removed" and you have described a database write.
References & further reading
- RFC 9420, The Messaging Layer Security (MLS) Protocol — Remove proposals and the Commit that rekeys the epoch: the group-scale version of section 9's boundary.
- Cohn-Gordon, Cremers, Garratt, On Post-Compromise Security — the formal statement of what rekeying after an eviction does and does not give you.
- RFC 7009, OAuth 2.0 Token Revocation — the standard shape of the thing section 8 says auth-svc lacks: an endpoint that takes a bearer token back.
- Certificate revocation list — the classic "revocation is a question someone has to ask" problem, and the same failure mode: a checker that never checks.
- Joining and Leaving and Mechanism: Absorbing a New Device — the roster this article writes to, and the direction that composes cleanly.
- Mechanism: Deleting Things That Stay Deleted
— why
revoked_atis a mark and not a delete, argued in the CRDT setting. - Who Acts — the seat election section 4 watches split in two.
- Algorithm: Who Gets to Put You in a Group — group membership as an admission problem, and the removal-commit rekey that bounds what a departed member retains.