Protocol · 02

Identity and key delivery

An album key has to reach another phone, often while that phone is switched off. Miuchio does this with a handshake based on Signal's X3DH. This page assumes you know X3DH and only covers what I changed and why.

Keys on each phone

KeyTypeLifetimeJob
IK, identity keyEd25519PermanentSigns everything: prekeys, deliveries, epoch changes, join receipts
LK, long-term keyX25519PermanentKey agreement only
SPK, signed prekeyX25519Replaced after 30 daysKey agreement, published in advance
OPK, one-time prekeyX25519One deliveryOptional fourth key agreement step
EK, ephemeral keyX25519One delivery, never storedThe 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 X3DHMiuchioWhy
Identity keyOne X25519 key used for both DH and signing, via XEdDSAEd25519 IK only signs. A separate X25519 LK does the DHNo key is converted between curve forms or used for two jobs, and the signing key stays a standard Ed25519 key
DH1 and DH2DH(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 inputF ‖ KM, zero salt, an application info stringKM, salt vault-x3dh-v1, info LK_A ‖ LK_B ‖ album_idThe same two phones get a different secret for every album, and both long-term keys are bound into the secret
What the secret is forThe start of a Double Ratchet sessionEncrypts album keys for one delivery, then is overwrittenAlbum keys must be kept anyway, so there is no session to continue
SPK signatureOver the encoded SPKOver 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:

  1. Every key is 32 bytes and the signature is 64 bytes.
  2. The SPK timestamp is within 90 days of the phone's clock.
  3. The SPK signature verifies under the bundle's own IK.
  4. 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

  1. Fetch the wrap addressed to this phone's member token for that epoch.
  2. Look up the sender's IK and LK in the album's member list.
  3. Decide whether that IK may sign for that sender: the signer check.
  4. Verify sender_sig.
  5. 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.
  6. Decrypt the wrap.
  7. 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.