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 and different trust anchors, and they fail in different directions. 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.
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 (its public key, the shareable half of its identity keypair) in the account's roster, the list of devices the account recognises as its own? And did something with the authority to put it there actually 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 row in a database that expires after three minutes. The second is settled by a device exporting its entire SwiftData store, the on-device database where all of that lives. It encrypts the export and parks the ciphertext (the encrypted bytes) on a service that will hand them to anyone who asks 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. If the snapshot has not started arriving after a
thirty-second grace period, it lets the user continue anyway. The steady digest reconcile (the
background loop that compares per-category summaries between devices and pulls whatever differs)
will backfill the store later. There is no equivalent "continue anyway" for authorization.
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 (the backend service that keeps the device roster) 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 first half of the mechanism buys an inversion of that model. 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 server's table of password hashes, 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. 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 which devices are your siblings (the other machines already on your account); it cannot become one.
Start with the difficulty, because it is genuinely awkward. The new device is pre-session: it has no bearer token (the credential that normally rides along with every request, granting access to whoever holds it), no account id, no keys anyone has ever seen. And the relay (the server that forwards encrypted frames between devices) does not accept anonymous traffic. Every send is gated on a rate-limit token, a small spend-once credential proving the sender has budget left. 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 (a one-time passcode sent by text message) for a number. The cert
is signed twice over, with Ed25519 (a classical elliptic-curve signature scheme) and ML-DSA-65 (a
post-quantum lattice scheme, built to hold even against a quantum computer), so it stays valid
unless both are broken. It is cached in the keychain, the phone's secure credential store. 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 address where first-contact messages for the account arrive. 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 (the
address the relay routes frames by) and a 1216-byte
X-Wing public key. X-Wing is the hybrid
key agreement that runs a lattice scheme and a classical elliptic-curve one together, so a box
sealed to it stays shut unless both are broken. The endpoint never returns an account id, and
never a device pubkey.
The directory holds one such pair per phone_id. The inbox behind it belongs to the
account, not to any one device: the discovery key is replicated to every sibling. So the
request lands on a single subject that every online sibling is listening on, and whichever
device the user is actually holding can answer it. (A sibling never mints its own inbox. A
locally-minted inbox would fork the account's discovery identity (split it into two competing
versions) exactly as a locally-minted account key would fork its trust root. See Rotating the Key Strangers Seal
To.)
What comes back is an unproven database read, so it is checked against the transparency log (a public, append-only record of key bindings that lets a device verify what a server claims rather than take its word) before anything is sealed to it. That gate matters more here than almost anywhere. What rides on the answer is a request whose approval carries the account signing seed, so a substituted key would be handed the account. The check fails closed: if the log cannot prove the binding, the sign-in does not proceed. That is the right asymmetry even though the same service serves both the record and the proof.
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: "pinned" meaning fixed in the service's
configuration ahead of time, not fetched on demand. 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. An OPRF,
an oblivious pseudorandom function, is a keyed hash the server computes for you without ever
seeing your input; these are the contact-discovery keys under which phone numbers are blindly
turned into lookup ids, and every client is meant to share the same list. The verifier is 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. A server that quietly hands this device a
per-user key, instead of the shared one, can then recognise it by that key's cleartext
key_id from the very first discovery frame of its life: the device is tagged from birth.
With an inbox and tokens in hand the device assembles a SignInRequest: device model, a fresh
random 32-byte reply subject (the return address for the answer), its own X-Wing public key, a
timestamp, and the cert. It seals the request 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.
On the other side, handleSignInRequest runs a ladder of guards. Every rung's failure is a
silent drop; the request is simply discarded, with no reply:
signin_request;phone_id equals this device's own phone_id;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: 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.
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 throwaway return address from the request. The new device redeems the grant through
the ordinary register-with-grant passkey enrollment (a passkey being the device's built-in
cryptographic login credential) with revoke_others: false, 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, its label in the key-transparency log. 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 (a message authentication code, the short tag that
proves a frame was written by a holder of a key and altered by nobody since) under a
sibling channel key. 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 discovery channel.
The reply is not, however, sealed to the requester's public key, and the distinction is the whole security argument. It is encrypted under the shared secret that the request's KEM established (a KEM, or key encapsulation mechanism, being the scheme that locks a fresh random secret to a public key so only that key's holder can recover it). A fresh-random filler occupies the ciphertext slot where an encapsulation would otherwise sit. That matters because the relay knows the reply subject (it is the routing key), so a reply that anyone could construct for that subject would let the relay itself forge an approval.
Keying the answer from the request's shared secret means only a party that could open the
request can answer it, which is precisely the sibling holding the account's discovery key.
SignInApprovalRequester adopts the seed before surfacing the grant, so that 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.
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 (one per category of data) 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 (the style of system that records every change as an event and rebuilds state by replaying them) will assume the account has an operation log: every insert, edit and delete in order, which 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 (whichever edit came later in cause-and-effect order survives), and the losing version is simply gone. Message edits and deletes fold into the surviving record. And tombstones (the small markers that record a deletion happened) are the one genuinely operation-shaped thing in the system, and they 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 (a way to bring deleted data back), and every garbage collection the barrier round worked to justify would be undone by unboxing a phone.
SyncService's header documents the whole thing; here it is with the reasoning attached.
The new device calls GET /v1/sync/siblings (session-authed, meaning the request must carry a
valid login session, with the account taken from that 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 repeated requests are
trivial to deduplicate.
A sibling that accepts the request (section 8) exports with BackupService.exportRawPayload and
seals with BlobCrypto.seal: a fresh random AES-256-GCM key (a standard authenticated cipher)
and a 12-byte nonce, a number used once and never reused. It uploads
the ciphertext to blob-svc (the blob-storage service) under the mime type
application/x-pixie-sync, and replies with a SyncOffer
carrying {blob_id, key, nonce, sha256, size_bytes}. That mime type 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 (SHA-256 being a cryptographic
fingerprint of the exact bytes) 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 (a crate is a Rust package) say the blob_id is the
capability: knowing the name is the only credential. 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 (it forwards frames and stores nothing,
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 (the wake class being the one coarse hint the
relay gets about what sort of frame it is carrying). The relay turns that class into a reliable
alert-class push, strong enough to relaunch a force-quit app, and then dedups the wake until
that device reconnects. The later re-sends only keep the frame available for a sibling that is
still booting.
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 with the
hybrid pair, classical and post-quantum both. 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. These are 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.
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 (time-to-live, the deadline after which the blob is deleted) 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 (peer-to-peer,
device-to-device) 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 (end-to-end encrypted)
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.
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 service hands the problem to its caller. 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 (encrypting even the
envelope's outer metadata) 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 structurally harder. That is Mechanism: Revoking a Device, which is written before the fix ships and says so.
BlobCrypto, and the reason a nonce is
never reused across blobs.