GroupThink
Mechanism: Absorbing a New Device
21 min read
Take a phone out of its box, sign in, and two completely different things have to happen: the account has to decide this machine is allowed in, and the machine has to acquire everything the account already knows. On screen they are one spinner. Underneath they use different keys, different trust anchors, and fail in different directions — and the second one is a whole-store transfer that no server is permitted to read, verified against a hash that has to arrive by a different road than the bytes it describes.
Prerequisites: Joining and Leaving for where the roster lives and why membership is server-authoritative, and Staying in Sync for the loop this device joins once both halves are done.
1. One spinner, two problems
A new device asks two questions, and they sound so similar that it is easy to read them as one.
May I join? — a question about membership. Its answer is a fact about a set: is this device's pubkey in the account's roster, and did something with the authority to put it there do so.
What do I know? — a question about state: every conversation, contact, memory event, notebook note, saga chapter and plan the account holds.
They have nothing in common except the wire they travel on. The first is settled by a human tapping Approve on a phone in their pocket, and its result is a three-minute row in a database. The second is settled by a device exporting its entire SwiftData store, encrypting it, and parking the ciphertext on a service that will hand it to anyone who asks for it by name.
They also fail differently, and that is the practical reason not to conflate them. A failed
authorization is a wall: the device does not join. A failed state transfer is a delay: the device
is already a member, already running the sync loop, and simply starts empty and fills in later.
OnboardingFlow encodes exactly that difference — after a thirty-second grace period, if the
snapshot has not started arriving, it lets the user continue anyway, because the steady digest
reconcile will backfill. There is no equivalent "continue anyway" for authorization.
2. What a server cannot admit you to
The familiar shape for adding a device is: present a secret to a server — a password, an SMS code, a QR payload — and the server, satisfied, declares you legitimate and hands over your data.
That shape assumes the server has your data. Here it does not. identity-svc could mark a row and say "this device is in the account," and the device would still know nothing: no messages, no contacts, no memory. Admitting a device to the roster is not admitting it to the account, because the account is the state, and the state is on the devices. The split is not a design preference; it is forced.
The inversion is what the first half of the mechanism buys. In the password model, a secret flows from the joining device to a server, and the server's verdict is the authorization. Compromise the verification path — steal the hash table, intercept the SMS, coerce the operator — and you can manufacture a legitimate device out of nothing. Here, nothing the servers hold is sufficient to admit a device. The grant that enrolls a new phone is minted by an already-enrolled device, on a human tap, and the material that makes the new device able to speak at all travels inside a box the servers cannot open. The existing devices reach out and take the new one in. A compromised identity-svc can lie about who your siblings are; it cannot become one.
3. Path one: bootstrapping with nothing
Start with the difficulty, because it is genuinely awkward. The new device is pre-session: it has no bearer token, no account id, no keys anyone has ever seen. And the relay does not accept anonymous traffic — every send is gated on a rate-limit token. So the device needs to send a message before it is allowed to send messages.
The one thing it does have is a PhoneOwnershipCert: an opaque blob phone-verify-svc mints
after the device passes SMS-OTP for a number, signed hybrid (Ed25519 and ML-DSA-65) and cached in
the keychain. It proves control of a phone number and nothing else — no account, no session. Two
endpoints accept it in place of a session, and both are the same shape.
POST /v1/discovery/own (lookup_own_discovery, identity-svc) resolves the caller's own account
discovery inbox. The cert's phone_id is the lookup key, which is the whole security argument:
there is no parameter to point elsewhere, so a caller can only resolve the number it has proven it
controls. It returns exactly {discovery_inbox, discovery_xwing_pubkey} — a 32-byte relay subject
and a 1216-byte X-Wing public key — never an account id, never a device pubkey. The directory holds
one such pair per phone_id, upserted, so what comes back is the inbox of whichever of your devices
published most recently: the request reaches one sibling, not a fan-out.
POST /v1/approval-tokens/challenge (credential-svc) mints a small batch of send-class rate-limit
tokens — four per request, refilled as the send loop spends them — budgeted against a bucket derived
from the phone_id rather than from any account.
Both refuse when the pinned phone-verify key is absent, and credential-svc says why in its own
comment: without the pinned key it cannot check the cert, so the caller's self-claimed phone_id
would become the entire authorization — and that value is what budgets the anti-abuse bucket.
Refusing to issue is the only safe reading of "I cannot verify this."
One rejected shortcut is recorded in SignInApprovalRequester. The token mint takes a verifier for
the served OPRF key list, checked against the transparency log, and this call site used to pass
nothing at all. That is the worst possible place to skip it: a pre-session mint on a brand-new
device is the one moment with no prior state to contradict the server, so a server that hands this
device a per-user key tags it by its cleartext key_id from the very first discovery frame of its
life.
With an inbox and tokens in hand the device seals a SignInRequest — device model, a fresh random
32-byte reply subject, its own X-Wing public key, a timestamp, and the cert — into a DiscoveryBox
addressed to the account's discovery key, and posts it to the inbox. It subscribes the reply subject
before sending, so a fast acknowledgement cannot arrive before anyone is listening.
4. What the sibling checks, and what refusal means
On the other side, handleSignInRequest runs a ladder of guards, and every rung's failure is a
silent drop:
- the payload decodes as a
signin_request; - the cert verifies against the pinned phone-verify key — both halves, Ed25519 and ML-DSA-65 — and has not expired;
- this device has its own cached cert to compare against;
- the request's
phone_idequals this device's ownphone_id; - the timestamp is within ±120 seconds.
Step 4 is the one doing the interesting work. A discovery inbox is not a secret — it is published in
a directory so that contacts can find you. Without the phone_id equality check, anyone who had
resolved your inbox could raise a Sign in on your iPhone? prompt on your lock screen at will.
Requiring the certificate to be for this number means the prompt can only be raised by something
that passed SMS-OTP for your own phone.
Only after all five does the device do two things: seal back a signin_ack carrying its own device
family — which is what lets the new device display Approve on your iPhone instead of an unlabelled
wait — and surface an actionable notification with Approve and Reject.
Now, precisely, what fail-closed means here. If no pinned key is configured, verification returns nil and the request is dropped. If no sibling is online, nothing answers. If the user taps Reject, an approval is sealed back carrying no grant. In every one of those cases the new device does not join — not provisionally, not with reduced privileges, not pending later confirmation. The reason is structural rather than a policy choice: there is no unverified state to join in, because the only thing that admits a device is a grant, and a grant only exists if a member minted one. The absence of an answer and an explicit refusal produce the same outcome, which is nothing.
That has a cost, and it is the honest counterpart to the guarantee. A user whose only other device is dead or lost cannot sign in on this path at all.
5. What approval actually hands over
Tapping Approve mints a device grant — POST /v1/auth/devices/grant, a row in recovery_grants
with a three-minute expiry — and seals a signin_approval back to the requester's ephemeral reply
subject. The new device redeems it through the ordinary register-with-grant passkey enrollment with
revoke_others: false, which is the same path the plainer six-digit-code flow uses. So far this is
just a token.
The interesting part is what rides beside it. The approval also carries the account signing seed (32 bytes) and the account's KT handle. This is the moment the account admits the device, and it has to be this moment, for a reason worth following.
Every envelope between siblings carries a MAC under a sibling channel key, and
ChannelKeys.resolve derives that key from the account seed: when an inbound cert carries our own
userIDHash, the key is InnerMAC.siblingChannelKey(accountSeed:) and nothing else. Resolution is
fail-closed — no key means the envelope is rejected on receive and the send throws.
Now count the dependencies. The earlier design bootstrapped the account key over a sync_delta
frame: ask a sibling for the key, sibling answers. But a sync_delta is a sibling frame, so
sending it requires the sibling channel key, which derives from the seed you are asking for. The
request cannot be MAC'd, so it is rejected; the answer cannot be MAC'd either. The bootstrap needed
the thing it was bootstrapping. The fix is not to weaken the MAC but to move the seed onto the one
channel that does not depend on it — the X-Wing sealed box addressed to the requester's ephemeral
discovery key, which the new device generated itself and no server has ever seen.
SignInApprovalRequester adopts the seed before surfacing the grant, precisely so the first
sibling frames it sends — the sync request in section 7 — can be MAC'd at all.
This is the seam. Everything up to here is authorization. Everything after it is state.
6. Path two: why a snapshot and not a replay
The device is now a member with an empty store, and the obvious move is to let the normal sync loop handle it. Reconciling by Digest already compares per-namespace summaries and pulls whatever differs; an empty store differs from a full one everywhere, so it would pull everything. Correct, and a bad idea: a difference engine asked to transfer a whole store does it one mismatched range at a time, over a channel sized for single-row deltas.
The deeper reason is not performance. It is that there is nothing to replay. A reader coming from event-sourcing will assume the account has an operation log — every insert, edit and delete in order — that a new device could replay from the beginning. That log does not exist. The reconcile protocol ships rows, not operations. Notebook edits resolve by causal newest-wins and the losing version is simply gone. Message edits and deletes fold into the surviving record. And tombstones, the one genuinely operation-shaped thing in the system, are deliberately collected: the whole subject of The Convergence Barrier and the Cloud Anchor is proving it is safe to delete them.
So the only durable artefact anywhere in the fleet is the current fold — the state after every operation has been applied. A snapshot is not a substitute for the log. The snapshot is the log's fold, and the fold is all that was ever kept.
One step further gives a property easy to mistake for a limitation: a new device cannot learn anything its siblings have already garbage-collected. Deleted notes are not recoverable from it, and neither are the tombstones that recorded the deletions. That is exactly right, and it is the convergence barrier's argument from the other end — if a freshly-absorbed device could reconstruct pre-deletion state, absorption would be a resurrection vector, and every GC the barrier round worked to justify would be undone by unboxing a phone.
7. The exchange, step by step
SyncService's header documents the whole thing; here it is with the reasoning attached.
The new device calls GET /v1/sync/siblings — session-authed, account taken from the session, so it
can enumerate only its own devices — and filters its own pubkey out of the returned list. An empty
remainder is the .noSiblings phase.
To each sibling it sends a SyncRequest: a 16-byte request_id, a reply_pubkey, and a timestamp.
That reply pubkey looks redundant until you remember the transport. Sealed sender means the outer
envelope carries no from field at all; the sender's identity comes out of the SenderCert after
decryption. There is no return address on the outside of the letter, so the request writes one
inside. One stable request_id per sibling is reused across re-sends, so an offer maps back to who
to acknowledge and repeats deduplicate trivially.
A sibling that accepts the request (section 8) exports with BackupService.exportRawPayload, seals
with BlobCrypto.seal — a fresh random AES-256-GCM key and 12-byte nonce, never reused — uploads
the ciphertext to blob-svc under the mime application/x-pixie-sync, and replies with a SyncOffer
carrying {blob_id, key, nonce, sha256, size_bytes}. That mime is not decoration: blob-svc's
cap_for_mime gives it the larger sync_max_blob_bytes ceiling, because a whole-device snapshot
dwarfs a chat attachment.
The new device downloads, and then does the step whose ordering matters: it checks
SHA256(ciphertext) == offer.sha256 before decrypting, and treats a mismatch as a hard failure
that acks ok: false. Two reasons. First, blob-svc's GET is deliberately unauthenticated — the
crate's own docs say the blob_id is the capability — so the bytes arrive over a channel with no
authentication at all. Second, and this is the part worth internalising: the hash and the bytes
travel by different roads. The bytes come from blob-svc; the hash came inside a sealed, MAC'd,
end-to-end envelope. An operator who can rewrite the blob cannot rewrite the hash it will be checked
against.
Then BlobCrypto.open, BackupService.importRawPayload, and a SyncAck. Import is
insert-if-absent keyed by row id (by planID for plans), so re-importing is a no-op — which is
what makes the exchange safe to retry.
Retry is the last piece. The relay is pure-ephemeral, with no mailbox, so a one-shot request aimed
at a sleeping sibling is simply lost. SyncService re-sends every 5 seconds up to 7 times, about 35
seconds of coverage. Only the first send push-wakes: SyncRequest is the sole payload type mapped
to WakeClass::Sync, which the relay turns into a reliable alert-class push able to relaunch a
force-quit app, and the relay then dedups the wake until that device reconnects. The later re-sends
only keep the frame available for a sibling that is still booting.
8. The one line that carries the trust
guard isSameAccount(senderUserIDHash) else { return }
It opens handleRequest and handleOffer both, and isSameAccount is three lines: the device's own
myUserIDHash must be 32 bytes and must equal the hash on the envelope. A nil own-hash returns
false, so a device that does not yet know its own account refuses everything.
The value is not self-asserted. senderUserIDHash comes from the verified SenderCert recovered
during decryption — a certificate credential-svc issues by computing
compute_user_id_hash(user_id) from the session's account and signing the result hybrid. A device
gets a cert bearing your account's hash by holding a valid passkey session for your account, and by
no other route.
What the check blocks is not symmetric. An unfiltered offer is the dangerous direction. Look at
what importPayload writes for each contact: pubkey, userIDHash, accountSigningPubkey,
channelKey — the values deciding who your device believes it is talking to and under which keys. A
sync_offer accepted from a stranger would not merely inject false rows; it would install an
attacker-chosen key for every person you know, dressed as a routine restore. The trust rule turns a
key-substitution attack into a dropped packet.
An unfiltered request is milder: anyone who learned a device's address could make it export its entire store, encrypt it and upload it — not readable without the key from the sealed offer, but your phone doing the work, on your data, on demand.
Note how this composes with the roster. Joining and Leaving established that identity-svc is authoritative for membership; this is the second gate. The server can tell your iPad that some pubkey is its sibling. It cannot make your iPad accept a snapshot from it. The directory says where to ask; the certificate says who is asking.
9. The key that is deliberately not used
Pixie already has a whole-store export: BackupService.export writes a .pixie-archive sealed under
a per-device BackupKey, resolved from the Keychain and synchronised across a user's devices by
iCloud Keychain. Reusing it here looks obvious, and the import function shows why it was not.
BackupService.import(from:) throws BackupServiceError.missingBackupKey when the key is absent,
and the comment names the case exactly: on a fresh device the user must wait for iCloud Keychain, or
be on the same Apple ID. Same Apple ID. The premise of this path is a sibling that is not in
the same ecosystem — the "Device-only" account with no Apple in the loop. A snapshot sealed under a
key distributed by iCloud Keychain is, for that sibling, a file it structurally cannot open.
So exportRawPayload produces an unsealed payload, and confidentiality is assembled from two
layers instead: the per-blob AES-256-GCM seal, whose key and nonce ride inside the sealed
sync_offer, and the sealed-sender envelope that carries the offer. blob-svc sees random bytes with
a 24-hour default TTL and no idea whose they are.
Be exact about the trade. The file export's guarantee is at rest and standing alone: hand someone
the archive and it is opaque without a key they cannot obtain. The P2P blob's guarantee is
conditional on the envelope — the ciphertext is opaque to anyone who did not open one specific
sealed offer, which given isSameAccount means one of your own devices. The second is the only one
available when the receiver may not share your key custody.
One consequence runs the other way. snapshotPayload takes an includeSecrets flag, and only the
P2P export passes true — provider API keys ride the same-account E2EE channel so the new device's
agent works without re-entry, and never appear in a shareable file. A path that gives up a key gains
the right to carry secrets a file must not.
10. What this does not give you
The snapshot is a photograph. It is exported at one instant and imported at another, and the fleet does not pause in between; a message arriving during the transfer is not in it. That is survivable only because the steady digest loop takes over immediately afterwards.
A sibling that vanishes mid-transfer fails in two different ways. If it drops after uploading,
the blob is already on blob-svc under its TTL and the fetch still succeeds; the sibling merely never
sees the ack, which handleAck treats as informational. If it drops before uploading, no offer
ever arrives — and here the code is thinner than it should be. The re-send budget runs out after
about 35 seconds and nothing transitions phase out of .requesting; there is no timeout state.
What rescues the user is the onboarding screen's own 30-second grace, not the service. And a sibling
whose export or upload throws swallows the error as best-effort, so the requester cannot distinguish
"declined" from "broke".
Both halves require a willing, online sibling — one awake enough to receive a push and a human to tap Approve, then one awake enough to export and upload. Absorption is constructive: everything the new device gains, it gains from a member. Nothing here recovers an account whose devices are all gone.
The relay learns that an onboarding sync happened. Header encryption collapses almost everything
into one indistinguishable Silent class, but SyncRequest is the sole member of WakeClass::Sync,
because it needs a wake strong enough to relaunch a force-quit app. So the first frame of a new
device's life is distinguishable by class — deliberately, and only that frame; the offer, the ack and
every later delta are Silent.
Nothing here is a revocation. Absorbing a device is one direction of membership change; taking one back out is the other, and it is much less finished. That is Mechanism: Revoking a Device, which is written before the fix ships and says so.
References & further reading
- Joining and Leaving — the roster, the two authorities, and the device-authorization chain that decides who may extend it.
- Mechanism: The Convergence Barrier and the Cloud Anchor — why the fold is all that is kept, and why a new device inheriting deleted data would be a bug.
- Mechanism: Revoking a Device — the opposite direction, and why it is structurally harder.
- The Relay — sealed sender, ephemeral delivery, and why a request has to carry its own return address.
- Contact Discovery — the discovery inbox and the phone-ownership certificate this path borrows to bootstrap with no session.
- NIST FIPS 204, Module-Lattice-Based Digital Signature Standard (ML-DSA) — the post-quantum half of the hybrid signatures on the ownership certificate and the sender certificate.
- NIST SP 800-38D, Galois/Counter Mode — the AEAD
behind
BlobCrypto, and the reason a nonce is never reused across blobs.