Skip to content

X3DH key agreement

X3DH (Extended Triple Diffie-Hellman) allows a sender to establish a shared secret with a recipient who is offline, using a key bundle the recipient published in advance. Zentalk’s implementation follows the Signal specification over X25519, with Ed25519 signatures authenticating prekeys.

Implementations: zentalk/src/services/e2ee/x3dh/ (TypeScript) and zentalk-node/pkg/protocol/x3dh.go (Go). Both use the same DH computations, key bundle structure, and HKDF info string ("Zentalk X3DH Key Agreement").

Key Curve Lifetime Purpose
Identity key (DH half) X25519 Long-term Authenticates the owner in DH computations
Identity key (signing half) Ed25519 Long-term Signs the signed prekey; verified by peers
Signed prekey (SPK) X25519 Rotated after 7 days or 100 messages Medium-term DH key, signed by the identity key
One-time prekey (OPK) X25519 Single use, deleted after consumption Adds forward secrecy to the initial handshake

The Ed25519 signature covers keyId (4 bytes) || publicKey (32 bytes) || timestamp (8 bytes). Two metadata-minimization measures apply at generation time:

  • Timestamp quantization — the signed timestamp is rounded down to the UTC day boundary, so published bundles do not reveal exact key generation times.
  • Randomized key IDs — signed prekey and one-time prekey IDs are cryptographically random 30-bit values, preventing enumeration of how many keys a user has generated or consumed.

Clients maintain a pool of one-time prekeys: replenishment triggers when fewer than 5 remain, topping back up to 10. Prekeys older than 90 days (MAX_PREKEY_AGE) are rejected by initiators.

The published bundle contains only public material:

Key bundle (conceptual shape)
{
"address": "0x…",
"identityKey": "<X25519 public, 32 bytes>",
"ed25519PublicKey": "<Ed25519 public, 32 bytes>",
"signedPreKey": {
"keyId": 123456,
"publicKey": "<X25519 public, 32 bytes>",
"signature": "<Ed25519, 64 bytes>",
"timestamp": 1750000000000
},
"oneTimePreKeys": [{ "keyId": 654321, "publicKey": "<32 bytes>" }],
"registrationId": 42
}

Key bundles move through three authenticated endpoints (see the REST API and Authentication):

Endpoint Method Purpose
/api/keys POST Publish the caller’s key bundle to the mesh DHT
/api/keys/{address} GET Fetch a peer’s key bundle (signature-verified)
/api/keys/{address}/claim POST Claim a one-time prekey for session establishment
  1. On first login, the client generates its identity key, signed prekey, and one-time prekeys, then publishes the bundle via POST /api/keys.
  2. A sender fetches the recipient’s bundle via GET /api/keys/{address} and claims a one-time prekey via POST /api/keys/{address}/claim.
  3. The sender runs the initiator side of X3DH locally; no private key material ever leaves either device.

Before any DH operation, the initiator validates the bundle:

  1. Signature check — verify the signed prekey’s Ed25519 signature against the bundle’s ed25519PublicKey. Failure aborts with a MITM-detected error.
  2. Freshness check — reject signed prekeys older than 90 days or timestamped in the future.

Then, with a fresh ephemeral X25519 key pair EK_A:

DH computations (Alice → Bob)
DH1 = DH(IK_A, SPK_B) — authenticates Alice's identity
DH2 = DH(EK_A, IK_B) — authenticates Bob's identity
DH3 = DH(EK_A, SPK_B) — ephemeral forward secrecy
DH4 = DH(EK_A, OPK_B) — only if a one-time prekey is available
SK = HKDF-SHA256(DH1 || DH2 || DH3 [|| DH4],
salt = X3DH_SALT,
info = "Zentalk X3DH Key Agreement",
length = 32)

X3DH_SALT is a fixed 32-byte protocol constant providing domain separation (the TypeScript client uses a non-zero protocol salt; see zentalk/src/services/e2ee/constants.ts). After deriving SK, all raw DH outputs and their concatenation are zeroized. The implementation also rejects a derived key that is all zeros or of the wrong length.

The initiator then sends an initial message alongside the first ciphertext:

Field Content
senderAddress Initiator’s wallet address
identityKey Initiator’s X25519 identity public key
ephemeralKey The fresh EK_A public key
usedSignedPreKeyId ID of the SPK used
usedOneTimePreKeyId ID of the OPK used, omitted if none (0 is a valid ID)
kyberCiphertext, pqcEnabled Present only for the hybrid handshake

The responder validates key lengths (32 bytes each for identity and ephemeral keys), then mirrors the DH computations with its private halves and derives the identical SK via the same HKDF parameters.

Two hardening rules on the responder side:

  • Missing one-time prekey. If the initial message references an OPK the responder no longer holds (stale bundle, re-registration, already-consumed key), the responder must not silently fall back to 3-DH — that would derive a different key. The handshake fails with an error, requiring the sender to re-fetch the current bundle and establish a fresh session.
  • Immediate OPK deletion. A consumed one-time prekey is deleted and its private key zeroized as soon as DH4 completes, preserving forward secrecy.

The 32-byte SK seeds the Double Ratchet:

  • Initiator — generates a new ratchet DH key pair, computes DH(new_pair, SPK_B), and derives the initial root key and sending chain key via KDF_RK. The initiator’s ratchet is established immediately.
  • Responder — sets SK as the initial root key and uses its signed prekey pair as the initial ratchet DH key pair (as required by the Double Ratchet specification — the initiator’s first header is computed against SPK_B). The responder’s ratchet completes on the first received message, which triggers a DH ratchet step with a freshly generated key pair.
Property Provided by
Mutual authentication DH1 and DH2 bind both identity keys; SPK signature binds SPK to the identity
Forward secrecy Ephemeral key (DH3) plus one-time prekey (DH4)
Replay resistance Prekey freshness window, single-use OPKs, future-timestamp rejection
Deniability Standard X3DH property: no signatures over message content, only over prekeys

X3DH authenticates keys, not people. To rule out a compromised key server substituting identity keys, pair it with the Key Transparency Log and contact verification.