All articles

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.

REMOVED DEVICE SAME KEYPAIR, STILL HELD RE-REGISTER WORLD 1 · THE ROW IS DELETED NOTHING LEFT TO CONFLICT WITH INSERT SUCCEEDS NO TRACE IT EVER LEFT WORLD 2 · THE ROW IS KEPT, MARKED REVOKED_AT THE TOMBSTONE IS STILL THERE 409 · CONFLICT RESURRECTION REFUSED ABSENCE CARRIES NO EVIDENCE — STORE THE REMOVAL, NOT NOTHING

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.

THE REMAINING FLEET THE REMOVED DEVICE DEVICE A DEVICE B DEVICE C STILL RUNNING SIBLINGS() SIBLINGS() DEVICE A DEVICE B DEVICE A DEVICE B FILTER: REVOKED_AT IS NULL — C GONE ELECTION BY INDEX OVER THE LIST ITS OWN ROW IS REVOKED — NOT LISTED NOT IN LIST → HOLDSSEAT = TRUE DEVICE A DRIVER DEVICE C DRIVER RE-ELECTED — EXACTLY RIGHT HOLDS YOUR FULL REPLICA — AND ACTS TWO DEVICES THAT EACH BELIEVE THEY ARE DRIVING

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.

SENT BEFORE SENT AFTER ON ITS DISK, PLAINTEXT SEALED UNDER THE ROTATED KEY AS THE REMOVED DEVICE SEES IT REMOVED DEVICE FULL REPLICA — EVERY MESSAGE IT EVER READ NOTHING FROM HERE ON DECRYPTS THAT IS POST-COMPROMISE SECURITY REMOVAL · REKEY YOU CAN REVOKE A DEVICE'S FUTURE — NEVER ITS PAST

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