All articles

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.

ONE SPINNER · TWO PROBLEMS · TWO FAILURE MODES NEW DEVICE MAY I JOIN? · MEMBERSHIP A SIBLING TAPS APPROVE FAIL = WALL · NO JOIN ON APPROVE MEMBER WHAT DO I KNOW? · STATE ALREADY A MEMBER — STARTS EMPTY FAIL = DELAY SYNC LOOP BACKFILLS STORE FILLS AUTHORIZATION FAILS AS A WALL · STATE FAILS AS A DELAY

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:

  1. the payload decodes as a signin_request;
  2. the cert verifies against the pinned phone-verify key — both halves, Ed25519 and ML-DSA-65 — and has not expired;
  3. this device has its own cached cert to compare against;
  4. the request's phone_id equals this device's own phone_id;
  5. 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.

ASK A SIBLING FOR THE SEED ASK VIA A SIBLING FRAME NEEDS MAC KEY ← SEED SEED IS WHAT'S BEING ASKED FOR SEED · MISSING SEND THROWS · RECEIVE REJECTS SEED INSIDE THE SEALED APPROVAL SIBLING ACCOUNT SEED + GRANT · KT HANDLE X-WING SEALED BOX TO EPHEMERAL KEY NO SERVER SAW IT NEW DEVICE SEED ADOPTED → FRAMES MAC'D THE SEED RIDES THE ONE CHANNEL THAT DOES NOT DEPEND ON IT

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.

SIBLING NEW DEVICE BLOB-SVC SIBLING UPLOADS CT OPERATOR COULD REWRITE UNAUTHENTICATED GET BLOB_ID IS THE CAPABILITY SEALED OFFER · E2E MAC'D CARRIES SHA256 · KEY · NONCE OPERATOR CANNOT REWRITE THE HASH SHA256 MATCH? THEN DECRYPT CT THE HASH AND THE BYTES TRAVEL BY DIFFERENT ROADS

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