Keys on each phone
| Key | Type | Lifetime | Job |
|---|---|---|---|
| IK, identity key | Ed25519 | Permanent | Signs everything: prekeys, deliveries, epoch changes, join receipts |
| LK, long-term key | X25519 | Permanent | Key agreement only |
| SPK, signed prekey | X25519 | Replaced after 30 days | Key agreement, published in advance |
| OPK, one-time prekey | X25519 | One delivery | Optional fourth key agreement step |
| EK, ephemeral key | X25519 | One delivery, never stored | The sender's fresh contribution |
A new phone publishes 5 one-time prekeys during setup and tops the pool up to 20 in the background when the server reports it running low. Private keys are generated on the phone and saved before anything is published, so an interrupted setup resumes with the same keys.
The private keys are kept in one file encrypted by a key the phone's hardware protects. On Android that key is held in the Android Keystore (StrongBox when the phone has it). On iPhone the file is stored in the Keychain and encrypted with a key derived from one held in the Secure Enclave, which never leaves the chip. App code only reaches a key through a callback, and the buffer is overwritten with zeros when the callback returns.
Publishing prekeys
spk_sig = Ed25519(IK, spk_pub || u64_be(spk_ts))
rotation_sig = Ed25519(IK, "rotate-spk-v1" || spk_pub || u64_be(spk_ts))
replenish_sig = Ed25519(IK, "opk-batch-v1" || u32_be(N)
|| SHA256(opk_pub_1 || ... || opk_pub_N))- The server rejects an SPK whose timestamp is more than 5 minutes from its own clock.
- The server never replaces an account's identity key once one is set.
- An SPK rotation is two steps. The new key is saved on the phone as pending, published, and only promoted to current once the server accepts it. If the app is killed in between, it compares its pending key with the one the server advertises and either promotes or discards it.
How the handshake differs from X3DH
The handshake follows the X3DH specification in shape: three Diffie-Hellman values, a fourth when a one-time prekey is available, and HKDF over the result. These are the changes:
| Signal's X3DH | Miuchio | Why | |
|---|---|---|---|
| Identity key | One X25519 key used for both DH and signing, via XEdDSA | Ed25519 IK only signs. A separate X25519 LK does the DH | No key is converted between curve forms or used for two jobs, and the signing key stays a standard Ed25519 key |
| DH1 and DH2 | DH(IK_A, SPK_B), DH(EK_A, IK_B) | DH(LK_A, SPK_B), DH(EK_A, LK_B) | Follows from the split above |
| KDF input | F ‖ KM, zero salt, an application info string | KM, salt vault-x3dh-v1, info LK_A ‖ LK_B ‖ album_id | The same two phones get a different secret for every album, and both long-term keys are bound into the secret |
| What the secret is for | The start of a Double Ratchet session | Encrypts album keys for one delivery, then is overwritten | Album keys must be kept anyway, so there is no session to continue |
| SPK signature | Over the encoded SPK | Over spk_pub ‖ u64_be(spk_ts) | The timestamp lets both sides refuse stale or future prekeys |
SK = HKDF-SHA256(ikm = DH1 || DH2 || DH3 [|| DH4],
salt = "vault-x3dh-v1",
info = LK_A_pub || LK_B_pub || album_id,
len = 32)Fetching a prekey bundle
A bundle is IK, LK, the current SPK with its signature and timestamp, and one one-time prekey if any remain. The server hands each one-time prekey out at most once. A bundle can be requested by Miuchio ID when inviting, or by member token inside an album. Lookups are rate limited per requester and target, and lookups by Miuchio ID are also capped at 20 an hour per account. This slows enumeration of IDs; it does not prevent it.
The phone checks, cheapest first:
- Every key is 32 bytes and the signature is 64 bytes.
- The SPK timestamp is within 90 days of the phone's clock.
- The SPK signature verifies under the bundle's own IK.
- When starting an epoch, the IK must equal the one this phone has pinned for that member, or its own IK for its own wrap.
Steps 1 to 3 only prove the bundle is internally consistent: a server could build a consistent bundle around its own key. Step 4 is what stops that. Invites cannot run step 4 yet, for the reason given on Trust and verification.
The delivered album key
The shared secret encrypts one epoch's album key. The result is a wrap:
wrap = 0x01 || AES-256-GCM(key = SK, plaintext = MK_epoch,
aad = album_id (16) || u32_be(epoch))
61 bytes total
sender_sig = Ed25519(IK_sender, SHA256(album_id || u32_be(epoch) || wrap))The associated data pins the wrap to one album and one epoch, so the server cannot move it. The signature pins it to the sender. The server stores it with the sender's EK, the one-time prekey index used, and the sender's member token.
An invite uses one handshake and one secret to wrap every epoch key the album has. Each wrap has a fresh nonce, its own epoch in the associated data and its own signature. The details are on Albums and epochs.
Installing a delivered key
- Fetch the wrap addressed to this phone's member token for that epoch.
- Look up the sender's IK and LK in the album's member list.
- Decide whether that IK may sign for that sender: the signer check.
- Verify
sender_sig. - Recompute the secret. Try the current SPK, then a pending one, then the previous one, then one archived key, to cover deliveries made just before an SPK rotation.
- Decrypt the wrap.
- Install the key. The same epoch arriving again with different bytes is refused, and an epoch older than the newest one is only accepted while filling in history.
If any step fails, the album stops installing keys and shows the reason instead of carrying on with a key it could not verify.