Pixieby Sociofabric

Algorithm: Rotating the Key Strangers Seal To

Contact discovery ends by handing you a stranger's mailbox: an address to publish to, and a key to seal a first hello with. (To seal a message is to encrypt it so that only the holder of the matching private key can open it.) The series stops there, as though that binding were a fixed property of a person.

It is not. The key behind the address is replaced on a routine cadence. The address itself moves only when a device is removed. And for a day or two after either one, the old and the new both work. This article is that lifecycle: two rotations that look alike and do entirely different jobs.

Prerequisites: Bulk Membership PIR, which delivers the binding this article picks up (PIR is private information retrieval, a technique that lets your phone fetch a record from a server without the server learning which record it fetched), and The Transparency Log, whose append-only versioning (entries are only ever added, never edited or removed) is what makes a rotation expressible at all.


1. The object the series stopped at

A completed discovery gives you three fields, and it matters that they are three and not one:

discovery_xwing_pubkey   1216 bytes   what a first hello is sealed to
discovery_inbox            32 bytes   the relay subject that hello is published to
version                     4 bytes   monotone, one per rotation

In plain terms: the key is what a stranger encrypts a first hello to, the inbox is the address that hello gets posted to on Pixie's relay server, and the version is a counter that only ever counts up: one tick per rotation. The transparency log commits them as separate fields of one leaf (a leaf being one account's entry in the log's tree), not as an opaque blob. That is the whole hinge of this article. Because the key and the address are committed independently, the account can replace one without disturbing the other. The two operations that result have different costs, different reasons, and different guarantees.

The private halves live on the device as a small ring of epochs. An epoch is the pair that defines a reachable identity: the inbox, and the 32-byte seed the X-Wing keypair regenerates from. (X-Wing is the encryption scheme behind these keys: a KEM, or key encapsulation mechanism, which is how two parties agree on a shared secret over a public channel. It pairs a classical exchange, X25519, with a post-quantum one, ML-KEM-768, so an attacker would have to break both.) Each epoch also records the moment it was minted, which is what expiry reads:

public struct DiscoveryEpoch: Equatable, Sendable {
    public let inbox: Data     // 32 — the relay subject
    public let seed: Data      // 32 — regenerates the X-Wing keypair
    public let createdAt: Date // drives grace-window expiry
}

They are stored as a single item in the Keychain (iOS's encrypted on-device credential store) rather than one item per epoch. The reason is worth stating because it is easy to get backwards: KeychainStore has no way to list every item it holds, so epoch-suffixed items would be invisible to wipe(), and live private keys would quietly survive an uninstall.


2. Why a published key has to move at all

Every other key in the system gets forward secrecy (the property that stealing today's keys does not unlock yesterday's messages) from a ratchet. A ratchet is a mechanism where both sides, while online, keep exchanging fresh key material and discarding the old, so yesterday's traffic stops being decryptable by today's compromise. A cold-contact key cannot work that way. Its entire job is to be usable by a stranger who has never spoken to you, holds nothing of yours, and cannot do a round trip: they read the key out of the directory and seal. There is nothing to ratchet against.

So the window during which one compromised device key exposes inbound first-contact hellos is not bounded by any property of the key. It is bounded by how often the key is replaced, and by nothing else. That single observation is the reason the rest of this article exists.

The cadence is daily. The floor is not arbitrary. A rotation is not served until it has been folded into the transparency log (the log batches recent changes and publishes them as its next provable snapshot), so replacing the key faster than the log publishes those snapshots would advertise bindings that resolvers (the contacts' devices looking you up) cannot yet prove. And every rotation costs an append to the authenticated dictionary (the log's provable key-value store) per account. Daily sits comfortably above the floor and well below the point where the append traffic becomes the problem.


3. Two rotations, and why they are not interchangeable

The sealing rotation keeps the inbox and mints a fresh key:

public func rotateDiscoverySealingKey() throws -> DiscoveryEpoch {
    let inbox = try (currentDiscoveryEpoch()?.inbox ?? randomInbox())
    let fresh = try XWingMLKEM768X25519.PrivateKey()
    ...
}

The identity rotation replaces both:

public func rotateDiscoveryIdentity() throws -> DiscoveryEpoch {
    let fresh = try XWingMLKEM768X25519.PrivateKey()
    let epoch = DiscoveryEpoch(inbox: try randomInbox(), seed: fresh.seedRepresentation)
    ...
}

The difference reads as one line of code. It is actually the difference between a routine hygiene operation and a revocation: cutting off a device that should no longer have access.

Moving only the key is nearly free. Contacts pick it up the next time they resolve you (that is, look up your current binding in the directory), and nothing resubscribes. The relay subject (the inbox address on the relay server) is unchanged, so every live subscription, every cached binding, and every push registration keeps working. Moving the address as well churns all of that. The first cut of this rotated both every time, and paid the churn daily for a confidentiality gain of exactly zero: the address is not a secret, and hiding it from a contact who already resolved it protects nothing.

ONE LEAF · THREE COMMITTED FIELDS X-WING PUBLIC HALF 1216 BYTES INBOX 32 BYTES · RELAY SUBJECT VERSION MONOTONE SEALING ROTATION · DAILY REPLACED INCREMENTED UNCHANGED · NO RESUBSCRIBE IDENTITY ROTATION · ON REMOVAL REPLACED MOVED · ALL SUBS CHURN INCREMENTED SEPARATE FIELDS · ONE ROTATION CHEAP · ONE A REVOCATION

4. The grace window, and why it is a receiver-side decision

A rotation is not atomic across the people who care about it: it does not reach everyone at the same instant. Resolvers read the folded view of the directory, the last snapshot the log published, so for some interval after a rotation your contacts are still holding (and still sealing to) the previous key. If the device discarded that key the moment it minted a new one, every hello in that interval would arrive unopenable, and neither side would ever learn why.

So a superseded epoch stays openable:

public static let discoverySealingKeyLifetime: TimeInterval = 24 * 60 * 60
public static let discoveryGraceWindow: TimeInterval = 48 * 60 * 60
public static let discoveryEpochRetention = 2

A two-day window over a one-day cadence keeps exactly one full superseded epoch alive. The retention constant of 2 says the same thing a second way: the current epoch, plus the one still inside its window. Retaining more would multiply precisely the exposure the rotation exists to bound.

The part worth pausing on is that none of this needs server support. Opening a box is a local act with a local key; the directory is not consulted, and no proof is fetched. Retention is a decision the receiver makes alone about which of its own keys it is still willing to try. That is why the window can be generous without weakening anything the log asserts: the log has already moved on, and the device is simply declining to forget quite yet.

The inverse error is the dangerous one, and it is silent: drop an epoch that some contact is still sealing to, and every hello addressed under it is black-holed. Nothing fails loudly, because from the receiver's side an unopenable box is indistinguishable from noise.

TWO EPOCHS ALIVE · ONE ADDRESS ROTATION GRACE CLOSES SUPERSEDED EPOCH STILL OPENS INBOUND HELLOS CURRENT EPOCH WHAT THE DIRECTORY NOW SERVES A CONTACT WHO HAS NOT RE-RESOLVED SEALS TO OLD KEY OPENS ANYWAY THEN THE OLD KEY IS FORGOTTEN RETENTION IS LOCAL · WHICH OF YOUR KEYS YOU STILL TRY

5. The address no longer names the key

There is a consequence of keeping the inbox in place that is easy to miss and expensive to get wrong. Once a sealing rotation leaves several epochs sharing one address, the subject a hello arrived on no longer identifies which key opens it. Before rotation existed, "which key?" and "which inbox?" were the same question. They are not any more.

Anything that needs to name an epoch therefore identifies it by its public half instead, hashed down to a fixed 32 bytes with SHA-256, a standard cryptographic hash:

/// Stable 32-byte identifier for an epoch: SHA-256 of its X-Wing public half.
public func epochKeyID(for epoch: DiscoveryEpoch) throws -> Data

The receive path tries every retained epoch, so opening an inbound hello was never at risk: one of them works. The risk is on the reply.

The handshake transcript (the running record of everything both sides have sent during the handshake, which each side must independently reconstruct) binds both sides' discovery public keys. So a device that opens a hello under the superseded epoch and then answers under the current one produces a transcript the sender cannot reproduce.

The authenticator (the final check where each side proves it saw the same transcript) fails, the handshake dies, and there is no diagnostic anywhere, because every individual step succeeded.

Recording the opening epoch's key id at the moment the hello is opened, and answering under that same epoch, is what closes it.


6. One key for the whole fleet

Everything so far has described the discovery secret as though it belonged to a device. It does not. It belongs to the account, and every device on the account holds the same one.

This is a deliberate reversal of the more obvious design, and it is worth being direct about what it costs.

Per-device discovery keys would mean a compromise of one phone exposes only the hellos addressed to that phone. Sharing means a compromise of any one device exposes the account's inbound first-contact traffic, full stop.

That trade was made knowingly, and it follows the same line the rest of the multi-device design takes: the devices on an account are one another's equals, not a hierarchy with a weak tier.

What it buys is the thing users actually notice. A first-contact hello arrives for you, not for a particular handset, and a sign-in approval has to be answerable from whichever device you happen to be holding. Both require that any sibling (any other device signed into the same account) can open a box sealed to the account's discovery key. With per-device keys, the device that published most recently would be the only one that could, and approving a new phone would depend on which of your old ones you had left at home.

The secret travels on the ordinary sealed sibling channel, the encrypted channel the account's devices already use to talk to one another. It has to be that channel rather than the sign-in approval frame: the sibling channel key is derived from the account seed, the master secret every device on the account shares, so a device only has this channel once it already holds the account root, by which point the approval has done its job. Epochs travel newest-first, exactly as stored. A joining sibling therefore inherits the grace window rather than just the current key, and can open a hello still addressed to the previous epoch.

One consequence lands in a place you would not look for it. The relay pins a discovery subject to the first key that claims it (once a key has claimed an inbox, no other key may), and push registration is pinned the same way. If that claim were signed with a per-device key, whichever sibling connected first would own the account's inbox and lock out every other one. So the claim key is derived from the epoch seed, using HKDF, a standard recipe for stretching one secret into further keys:

let derived = HKDF<SHA256>.deriveKey(
    inputKeyMaterial: SymmetricKey(data: epoch.seed),
    info: Data("pixie:discovery-subject-claim:v1".utf8),
    outputByteCount: 32)

Every device holding the epoch derives the same keypair, so they all present the same claim and the pin admits all of them. It is also the more honest statement of the right being claimed: the entitlement to receive on a discovery inbox belongs to whoever holds the discovery secret, which is precisely the account. An outsider who merely learned the subject (and it is derivable from the directory) still cannot produce the signature. So the squatting attack the pin exists to stop (a stranger grabbing your inbox before you can) is unaffected.

For the same reason, a device never mints an epoch lazily. A joining sibling that minted its own would be reachable at an address no contact resolves, and would fork the account's discovery identity (split it into two diverging versions) exactly as a locally-minted account key forks its trust root. Epochs appear by rotation or by adoption from a sibling, and by no other route.


7. Removal has to move the address

Now the two rotations stop being a matter of taste.

When a device is removed from an account, the server can strike it from the roster, refuse its sessions, and stop answering its queries. What no amount of server work can do is retrieve the copy of the discovery secret already sitting in that device's Keychain. It has the seed. It knows the inbox. And because the subject claim derives from the seed rather than from the device, it can still present a valid claim for the account's discovery subject.

That last point is what makes a sealing-only rotation inadequate here. Move just the key, and the removed device can no longer read new hellos, but it can still subscribe to the address they arrive at, which means it can still receive them. A subscriber that receives and discards is a subscriber that denies: hellos are swallowed instead of delivered. The account would look reachable and silently not be.

So removal takes the identity rotation, unconditionally:

// Identity rotation, not just the sealing key: the removed device
// knows the inbox and can still claim its relay subject, so moving
// only the key would leave it able to receive and deny hellos.
_ = try await contactDiscoveryService.rotateDiscoveryIdentity()

The ordering is chosen so that a failure degrades safely. Mint and publish first: a failed publish leaves the previous epoch current, which keeps the account reachable rather than orphaning it. Then push the new epoch set to the siblings that remain. Then re-subscribe locally. And if the rotation itself fails, it says so loudly, because the alternative is a user who believes a revocation happened that did not.

Siblings that were offline for the push are not lost. The push is fire-and-forget with no delivery duty; the guarantee is the pull, which re-requests the account's epochs on every bootstrap. The push is only the fast path.

REVOCATION IS A CHANGE OF ADDRESS ACCOUNT INBOX · OLD EVERY DEVICE HOLDS THE SEED ACCOUNT INBOX · NEW GRANTED TO SIBLINGS THAT REMAIN THE REMOVED DEVICE STILL HOLDS THE OLD SEED AND ADDRESS STILL PRESENTS A VALID SUBJECT CLAIM A KEY ONLY THE SERVER COULD NOT TAKE BACK — AND NOBODY PUBLISHES THERE ANY MORE — YOU CANNOT TAKE A KEY BACK · SO YOU ABANDON THE ADDRESS

8. What a rotation hides, and what it does not

The honest accounting, because this is the section most likely to be overstated elsewhere.

A rotation is not retroactive. It bounds the window in which a compromised key is useful going forward. Hellos sealed to the old key before the rotation were decryptable by whoever held it, and remain so: the ciphertext does not become safer because a successor exists. The claim is about tomorrow, never about yesterday.

Removal is not instant. The removed device stops receiving when contacts stop publishing to the old address, which happens as they re-resolve, not at the moment the button is pressed. Until then the old inbox is still live and the old seed still opens what arrives there. The gap is the same interval that motivates the grace window (the log folding in the change, then contacts re-resolving), and it is short, but it is not zero.

The old key is gone from you, not from them. Retention expiry deletes the superseded epoch from your Keychain. It cannot delete the copy on a device you removed. Everything in §7 is arranged around that asymmetry: you cannot revoke a secret someone already has, so you revoke the address, which is the one half of the binding you still control.

Rotation is not unlinkability. Unlinkability would mean an observer cannot connect a log entry to a person; rotation does not provide that. The version counter only ever goes up and the log is public, so an observer who can already identify an account's leaf sees that it rotated, and when. What the log does not give them is which phone number the leaf belongs to: the label is a keyed PRF of the identifier (a pseudorandom function: a keyed scramble whose output is useless without the key), not a plain hash anyone could recompute from a guessed number, which is the property What a Lookup Proof Binds establishes. Rotation neither strengthens nor weakens that; it is simply a different question.

One compromised device compromises the account's inbound discovery. Stated once more, plainly, because it is the cost of §6 and it should not be discovered by reading source. Sharing the discovery secret across the fleet is what makes any-device approval and account-addressed hellos work, and it means the blast radius of a single compromised handset is the account, not the handset.


References

Back to Bulk Membership PIR, which hands you the binding this article puts a clock on, or across to Absorbing a New Device, which is the approval flow the fleet-shared key exists to serve.

← Algorithm: What a Lookup Proof Binds