Pixieby Sociofabric

Mechanism: Revoking a Device

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, another device on the same account; a link in the device-authorization chain (the signed record of which devices the account has admitted) signed by a device that was already in the previous roster, the account's device list as it then stood. 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 replica (a full local copy, in its on-device SwiftData store) of your messages, posts, calls, notebook and memory log. It also holds the libsignal double-ratchet sessions for every conversation you have (libsignal is Signal's encryption library): the per-conversation key state that steps forward with each message, and the thing that actually decrypts the traffic.

It also holds the account signing seed (the secret a sign-in approval hands to every enrolled device) from which InnerMAC.siblingChannelKey(accountSeed:) derives the channel key that defines the same-account sibling channel. And it holds a bearer token (a credential granting access to whoever presents it) 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 (a migration being a versioned change to the database schema), 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 re-registering the same pubkey (its public key, the shareable half of its identity keypair) 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 (HTTP's "conflict" status), 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 the device 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 DEPARTURE TRACE 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 (a marker that records a removal), 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 are session-authed (the caller must present a valid login session), and both are idempotent, so running one twice changes nothing more than once:

// 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.

The one part that is not a mark

Alongside that column there is an operation that is not a server flag at all, and it is the only step in a removal that takes a live capability away rather than recording an intention.

The account's discovery identity (the key strangers seal a first hello to, and the inbox they post that hello to) is one identity per account, replicated to every device. That is what makes approve-on-device work from whichever handset you happen to be holding. It also means the removed machine has a copy, and no server can reach into its Keychain (the phone's secure credential store) and take it back.

So removing a device mandatorily rotates that identity: a fresh sealing key and a fresh inbox, published to the directory, with the new epoch set (the rotation's new generation of key and address) handed to the siblings that remain. Both halves have to move. Rotating only the key would stop the removed device reading new hellos while leaving it able to subscribe to the address they arrive at. And a subscriber that receives and discards is a subscriber that denies service.

The full argument, including why the grace window exists and what the rotation does not undo, is Rotating the Key Strangers Seal To.

The rotation needs nothing from the removed device, which makes it the exception to everything section 4 is about to say. It is also loud on failure: if it does not complete, the log says so explicitly, because the alternative is a user believing a revocation happened that did not.


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() runs; then userDefaults.set(false, forKey: "pixie.onboarded") clears 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 (the vote that picks which single device runs the account's autonomous agent work) 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. That is deliberate: 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 sending sync_delta frames to 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. Every backend service that defines an authentication context defines the same struct:

pub struct AuthContext {
    pub user_id: Uuid,
}

identity-svc's require_session middleware (the check that runs before every handler) 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, the built-in cryptographic login credentials), 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. A sender certificate is the credential that rides inside a sealed envelope to tell the recipient which account sent it, the outside carrying no return address. 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. relay-svc (the server that forwards sealed envelopes between devices) then gates admission on the certificate's TTL (its time-to-live), a rate-limit token (a spend-once credential proving the sender has budget left), 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 (delivering one message by sending a separately sealed copy to every member device) 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 (the public keys a stranger fetches to start an encrypted session with a device that may be asleep), alongside claim_key_package, the same service for MLS (Messaging Layer Security), the group-messaging protocol group threads run on.

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 (the opaque addresses frames are routed by) on the socket's query string and has no session at all. That is by design, since the relay must not know who is behind a subject.

Pair subjects are derived from (channelKey, devicePubkey, epoch), the epoch being a counter that steps forward on each key rotation, and the removed device holds every input, so nothing stops it subscribing. What stops delivery is that senders no longer publish there. For a pair subject, revocation is enforced at senders and can only ever be enforced at senders.

The one address that is not like this is the published discovery inbox, and it is instructive that the exception exists for an unrelated reason. Because that address is directory-derivable, the relay pins it to the first key that proves possession, so a stranger cannot squat on it (Who Gets to Listen).

But the claim key is derived from the discovery secret itself, which the removed device still holds, so it passes that gate too. Which is exactly why removal moves the address, as the rotation in section 2 describes: the pin cannot evict a device that legitimately knows the secret, so the account stops using the address the secret opens.


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, the guarantee that watching envelopes does not reveal which devices belong to whom. 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} (the sibling list plus this device itself), caches it, and calls pushDeviceListToContacts only when the CSV (the set serialised as one comma-separated string) 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 (inserts or updates) 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 (the one their app first exchanged keys with), 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 that was bound to the transparency log (the public, append-only log of key bindings) intersects the fan-out set against the chain's authorized roster. It 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; otherwise a caller could name someone else's device and the binding would be decorative. Every AuthContext struct gains 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. That 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 property has a name: post-compromise security, the guarantee that after a rekey the party you excluded reads nothing new. 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

← Mechanism: Absorbing a New DeviceMechanism: The Driver's Seat →