GroupThink
Joining and Leaving
21 min read
Sync is defined over a set — "the account's other devices" — and the previous article never said where that set comes from. It comes from a server, which is an uncomfortable answer in a system built so that no server can read anything, and the resolution is not to remove the server but to make its answer checkable by the devices themselves. Then comes the harder direction: taking a device back out, where a mark in a database meets a machine that already holds a complete copy of your life.
Prerequisites: GroupThink for the sealed same-account channel these devices talk over, and Contact Discovery for how an account comes to have a verifiable identity at all.
1. The set that sync assumed
Read Staying in Sync again with one question in mind: to whom? Advertise a digest — to whom. Notice a difference — with whom. Pull the rows you are missing — from whom. Every step in that loop is a verb with a missing object, and the object is always the same phrase: the account's other devices. The loop is a precise machine bolted to a set it never defines.
The obvious instinct is that the devices should work the set out among themselves. They already talk; let them gossip. That fails immediately at the case that matters most. A device that has just been unboxed holds nothing — no sibling pubkeys, no addresses, no shared secret, nothing to gossip to. Peer-to-peer discovery has to start somewhere, and "somewhere" is precisely what a new device lacks.
It fails a second time even for established devices, and the second failure is subtler. Membership is a claim about absence as much as presence. Your iPhone believes the account has two devices. Is that because there are two, or because it has not yet heard about the third? Nothing local distinguishes those. Gossip converges only if you can bound the set of gossipers, which is the question you were trying to answer. So there is a rendezvous point, and it is a server.
The cost of getting this wrong is quiet rather than loud. A device whose roster reads empty does not error. It sends nothing — the delta emitter bails on an empty sibling set — and behaves like a fleet of one, forever, with a perfectly healthy sync loop running against nobody.
2. Where the roster actually lives
The rendezvous point is identity-svc, and it is a deliberately boring service: one table,
identity_records, one row per device. The row holds the device's 32-byte Ed25519 envelope pubkey
(the primary key), its 1952-byte ML-DSA-65 envelope pubkey, its libsignal identity key and
registration id, a created_at, and — added by migration 0003 — a user_id linking the row to
an auth-svc account. That last column is membership. There is no roster table; a roster is a
SELECT ... WHERE user_id = $1.
Reading it is GET /v1/sync/siblings. The handler's shape carries most of the design:
SELECT pubkey, mldsa_envelope_pubkey FROM identity_records
WHERE user_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC
Three things about that $1. It comes from the session — never from a path or a body — so a
caller can enumerate only its own devices. It filters revoked_at IS NULL, which is section 7's
subject. And the ordering is newest-registered first, which the driver's-seat election in
Who Acts reverses to make the oldest device primary.
Now the contrast that makes the design legible. There is a reverse lookup — get_user_pubkeys,
public-read, "every envelope pubkey registered to a user_id" — and ordinary 1:1 messaging never
calls it. ComposeService.recipientDeviceSet reads only local storage and says so in a comment:
never IdentityClient.pubkeys(forUserID:), because a server reverse-lookup would let a sender
enumerate a target's devices and break sealed-sender unlinkability. A friend's device set is
learned only over the encrypted channel, from the friend. So the system answers two
similar-sounding questions in two completely different ways: which devices are mine comes from
the server; which devices are yours never does.
3. Two authorities, one account
Here is the tension this article exists to develop.
The roster is server-authoritative. identity-svc holds the list, and a device cannot talk its
way back into an account by asserting things — assertion is not how the list is written. The
content of the account is device-authoritative. Every sync_delta body is sealed end-to-end
to a specific sibling; the relay carries ciphertext it cannot attribute, and identity-svc, which
does hold cleartext, holds only public key material and an account id. Nothing in either service
can read a message, a note, or a memory.
Those two facts have to coexist, and a reader is right to be uneasy about the first. Why hand a server any authority in a system whose premise is that servers are not trusted?
Because of what the answer is worth to whoever takes it. The roster is a grouping of public keys under an account identifier — what a directory is for, and roughly what identity-svc learns anyway from the registrations it processes. Everything the roster points at is unreadable. Put the other way round: the server is trusted for the membership list precisely because it is trusted for nothing else. The design's job is to keep that trust narrow, not to eliminate it and quietly reintroduce it as a "sync server."
And the split has a matching rule on the device side, which is what stops server authority from
becoming server power. SyncService honours a sync_request or a sync_offer only when the
sealed envelope's verified SenderCert.user_id_hash equals its own. The server can tell your iPad
that some pubkey is its sibling. It cannot make your iPad accept a snapshot from it. Membership
and admission to state are two gates, and the server holds one of them.
4. Why the list cannot be believed on its own
The narrow trust still has an edge, and it is worth walking straight into it. register_identity
takes the user_id it writes from the caller's session. So anyone holding a session for your
account can put a row in your roster — and so, trivially, can whoever operates the database.
The first defence against that is the cross-signature. Every roster a device advertises to a
contact carries an account self-signing pubkey plus one DeviceCrossSig per device: an
account-signed attestation that device_pubkey belongs to the account identified by
user_id_hash, valid about a year. A contact pins that account key on first contact and
thereafter accepts only devices carrying a valid cross-signature under the pinned root; an
unsigned update, or one signed under a different root, is rejected outright as a downgrade
rather than merged. InboxService.verifiedDeviceSet returns nil for those, and the caller
advertises nothing. That closes roster poisoning by a lying relay.
It does not close account-key theft, and the reason is a counting argument. The account signing key is one keypair per account, minted by the founding device and replicated to every sibling — mint if and only if you are the sole device, otherwise adopt from a holder over the sealed channel. So the key lives on N devices, and N grows every time you add one. Anything the account key alone can authorize is therefore authorized by the weakest device you own. If holding that key is sufficient to say "this device is mine," a thief who gets it says the same sentence, signs a cross-sig for a device you have never seen, and your contacts fan every message out to it.
5. A device is admitted by a device
The fix is to change who is allowed to say it, and the mechanism is the device-authorization chain: an append-only, hash-linked list of roster states, each one signed by a device rather than by the account.
KTPublishLedger.verifyChain is the whole rule set, and it is short. Entries must arrive in
contiguous version order starting at 1, all under the same account handle. Each entry's device
signature must verify — hybrid, Ed25519 and ML-DSA-65, over a preimage that binds both of the
author's pubkeys. Each entry's prevEntryHash must equal the previous entry's hash. Each entry
carries the actual roster and must hash to the rosterDigest it commits, with no duplicate
devices. And then the two cases:
- Genesis (version 1). The account key signs a blessing over
pixie:kt-chain-genesis:v2 ‖ handle ‖ founder-ed ‖ founder-mldsa, and the founding device must appear in its own roster. - Every later version.
prevRoster.contains(authorPair)— the author's full hybrid pair must already have been in the previous entry's roster.
Follow that by induction. A device in roster N was put there by a device that was in roster N−1; that one by a device in roster N−2; and so on down to version 1, whose author the account key blessed. Therefore extending a roster requires an enrolled device's secret, not merely the account key. That is the property the chain buys, and it is exactly the property section 4 showed the cross-signature could not: the thief now needs to compromise a device, not a key that every device already carries.
Two details make the mechanism affordable rather than merely correct.
The log commits a hash, not a roster. The chain travels end-to-end encrypted inside the sealed
DeviceList.auth_chain blob, and the codec that reads it lives entirely on the client — the relay
and the backend never parse it. What goes into the public transparency log is the 32-byte
chainHead, folded into a leaf beside the account's two signing pubkeys. A server watching the log
sees a hash change when your roster changes and learns nothing about what changed, or how many
devices you own. (What that log is, and why an append-only commitment is worth anything, is
Algorithm: The Transparency Log.)
A contact binds the tip, not just the chain. verifyContactChain requires both that the chain
verifies under the pinned account root and that the tip's version equals the log's latest version
for that handle, with that version's committed leaf equal to
(pinnedSigning, pinnedMldsa, entryHash(tip)). The freshness clause does real work: a chain that is
genuine but truncated still verifies perfectly, and would roll a contact back to an older roster —
one that might still contain a device you removed. Requiring tip.version == latest refuses that.
It also disposes of the stolen-key genesis: a thief can mint their own version-1 chain, but version
1 is not the latest version of an established account, so the bind fails; and if the thief instead
publishes a new version to the log, it is public, and the owner's self-monitor sees a version its
ledger did not author. Neither a stealth roster nor a silent one.
At the send path the consequence is short and sharp. Once a contact has a bound roster,
recipientDeviceSet honours it verbatim — even if it is empty. The usual "fall back to the
primary pubkey" rescue is scoped to contacts with no bound roster, because rescuing would mean
sending to a device the logged chain may not authorize.
6. Absorbing: the fleet reaches out
With membership defined, the two directions of change become concrete, and they are not symmetrical. Start with the tractable one.
The instinctive shape for adding a device is: the new device proves who it is to a server, and the server lets it in. Under the split in section 3 that is backwards, because the server has nothing to let it in to. Admitting a device to the roster is not admitting it to the account; the account is the state, and the state is on the devices. So the operation splits in two.
Authorization. The new device is pre-session: it has no bearer token and no keys anyone knows.
It resolves its own account's discovery inbox, seals a SignInRequest, and posts it there. Online
siblings receive it, acknowledge — the acknowledgement carries the sibling's make and model, so the
new device can display Approve on your iPhone 17 Pro rather than a blank wait — and, when the
user approves on that other device, send back a sealed SignInApproval carrying an enrollment
grant, which the new device redeems through the ordinary register-with-grant path with
revoke_others: false. Authenticity runs on the phone-ownership certificate the new device earned
by passing SMS verification: a sibling checks the certificate's signature against the pinned
phone-verify key and that its phone_id matches the sibling's own cached one, so a contact who
merely knows your discovery inbox cannot raise a sign-in prompt on your phone. Unpinned means
fail-closed.
Notice the direction of travel: the existing devices reach out and take the new one in. There is also a plainer path — a signed-in device mints a three-minute grant with a six-digit code you transcribe — but even there the grant originates from a member, not from a password.
State. Once enrolled, the new device asks a sibling for everything: a sync_request, a sealed
sync_offer naming a blob and its key, a fetch, a SHA-256 check, a decrypt, a wholesale import. It
does not replay the account's history. Why a snapshot rather than a replay, and why the two halves
above look alike and are not, is
Mechanism: Absorbing a New Device.
7. Expelling is not the mirror image
Removal looks like addition with the sign flipped. It is not, and the asymmetry is structural rather than an artefact of this implementation.
Adding is constructive: to become a member you must acquire something you do not have — a grant, a snapshot, a chain link — and each requires cooperation from someone who already is a member. A failed add is a non-event; nothing happened.
Removing is destructive: it must take away things the target already holds. Its keys. Its bearer token. Its full replica. Its ability to keep asking questions. Writing a value into a database column does none of those. What it changes is what other parties learn when they ask — so the strength of a removal is exactly the number of places that ask, and that is an open set that grows with the system.
What exists today is one column and two readers. remove_sibling — session-authed, scoped to your
own account — runs UPDATE identity_records SET revoked_at = now() WHERE user_id = $1 AND pubkey = $2 AND revoked_at IS NULL, idempotently. revoke_all_siblings does the same across the whole
account, which is what account recreation uses while it still holds the old session. Two lookups
honour the flag: get_siblings and get_user_pubkeys. The by-pubkey lookups do not — get_identity
and fetch_bundle will still serve a revoked device's row and its prekey bundle.
Why mark the row instead of deleting it? Migration 0011 answers, and the answer is worth tracing
because it is the same lesson deletion teaches everywhere in this series. register_identity
inserts ON CONFLICT (pubkey) DO NOTHING and reports the conflict as a 409. Delete the row, and
the removed device re-registers the same pubkey and is silently back in the account. Keep the row
and mark it, and re-registration collides with a record that is still there. The retained row is a
tombstone: absence carries no evidence, so you store the removal instead of storing nothing.
It is also what lets the removed device find out — which brings us to the uncomfortable part.
Departure is cooperative. self_status is a public read keyed by the device's own pubkey (the
handler's comment: the pubkey is the identifier, and revocation status is not sensitive), and
checkSelfRevoked() polls it on the sibling-sync cadence; a revoked: true triggers
resetForRecreate() and flips the onboarding gate so the app returns to sign-in. Nothing compels
the poll.
And at the contact edge, section 2's privacy property becomes revocation's cost. Your friend's app never asks a server which devices you own. So nothing a server does can remove a device from your friend's fan-out; only a fresh signed roster and a new chain version, advertised over the encrypted channel and re-bound against the log, will. The design that refuses to let anyone enumerate your devices is the same design that cannot broadcast a removal. Mechanism: Revoking a Device takes that apart properly.
8. Revoking a future, never a past
State this one plainly, because it is the claim most often fudged.
The departing device holds a complete replica. That is not an oversight; it is the entire purpose of the machinery in Staying in Sync. Your messages, your notebook, your memory log, your contacts — all of it was deliberately placed on that machine, and no update to a column on a server un-reads it.
What removal actually buys is a list, and the list is worth being exact about. The fleet stops
addressing the device, because it drops out of get_siblings and therefore receives no further
deltas. It cannot return under the same identity, because the tombstoned row refuses the
re-registration. And if it cooperates, resetForRecreate() wipes its keychain identity and
deletes its messages, posts, calls, outbox, libsignal session blobs and per-row causal clocks.
Now read that last item for what is not in it. The code's own comment is "Drop identity-tied local data; keep Contacts + agent memory." A cooperative self-wipe leaves the contact list and everything the agent has learned about your life. And every item on the list is contingent on a device you no longer control choosing to run a check against a server it is free to ignore.
So: removal revokes a device's future — future deltas, future registration, future fan-out once your contacts rebind — and gives you nothing whatsoever about its past. Any system that tells you otherwise about hardware in someone else's hands is describing a wish.
9. What membership does not give you
Four limits, stated here rather than left for a reader to find.
Sessions are account-scoped, not device-bound. auth-svc's sessions table is
(session_token, user_id, issued_at, expires_at) — no device column — and identity-svc's
AuthContext carries a user_id and nothing else. A handler therefore cannot ask whether the
calling device is revoked, because it does not know which device is calling. Removing a device
does not invalidate its bearer token, and since remove_sibling is scoped by account rather than
by caller, a revoked device holding a live session can remove the device that removed it. This is
material for Revoking a Device; it belongs here because it is
a property of the split itself, not of that article's mechanism.
The chain ratifies the server's answer; it does not replace it. The roster a device signs into
the chain is assembled from identityClient.siblingDevices() — the server's list — plus its own
pair. So the chain proves an enrolled device signed off on a roster state. It does not prove a
human approved each member. What gates enrollment is the approval flow in section 6; what detects
an unapproved member is the owner's own self-monitor watching the log.
The chain only ever tightens. A contact who has never bound your chain stays on cross-signature trust, and an unverifiable or not-yet-logged chain leaves the previously authorized roster untouched rather than clearing it. That is deliberate — a publish lag must not brick delivery — but it means the strong property of section 5 holds for contacts who have bound your chain, not for all of them, and a reader should not round that up.
Publishing the roster is single-writer. Exactly one device advances the chain's version, gated on the driver's seat, because two devices publishing concurrently would race for the same version number. Which device that is, and what happens if it disappears mid-flight, is the question Who Acts opens.
References & further reading
- Mechanism: Absorbing a New Device and Mechanism: Revoking a Device — the two directions of this article, worked out in full.
- Algorithm: The Transparency Log — what it means for a chain head to be "committed to the log," and why an append-only commitment is worth anything.
- Melara et al., CONIKS: Bringing Key Transparency to End Users, USENIX Security 2015 — the origin of auditable key directories, and the ancestor of the log this system's roster head lives in.
- RFC 9162, Certificate Transparency Version 2.0 — the same idea at web-PKI scale: a log you cannot force to lie consistently.
- ELEKTRA (Len et al., multi-device key transparency) — the "device authorizes device" property
that section 5's
author ∈ prev-rosterrule implements, and which this system's architecture notes name directly as the model being followed. - NIST FIPS 204, ML-DSA — the post-quantum half of every hybrid signature in the chain; both halves must verify or the entry is rejected.
- Trust on first use — the pinning discipline the account key relies on, and the assumption the chain exists to stop leaning on.
- Contact Discovery and Sealed Sender — where account identity comes from, and why a contact's device set is never a server lookup.