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 types
Section titled “Key types”| 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 |
Signed prekey signature
Section titled “Signed prekey signature”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.
One-time prekey pool
Section titled “One-time prekey pool”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.
Key bundle
Section titled “Key bundle”The published bundle contains only public material:
{ "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}Publish and claim flow
Section titled “Publish and claim flow”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 |
- On first login, the client generates its identity key, signed prekey, and one-time prekeys, then publishes the bundle via
POST /api/keys. - A sender fetches the recipient’s bundle via
GET /api/keys/{address}and claims a one-time prekey viaPOST /api/keys/{address}/claim. - The sender runs the initiator side of X3DH locally; no private key material ever leaves either device.
Initiator computation
Section titled “Initiator computation”Before any DH operation, the initiator validates the bundle:
- Signature check — verify the signed prekey’s Ed25519 signature against the bundle’s
ed25519PublicKey. Failure aborts with a MITM-detected error. - Freshness check — reject signed prekeys older than 90 days or timestamped in the future.
Then, with a fresh ephemeral X25519 key pair EK_A:
DH1 = DH(IK_A, SPK_B) — authenticates Alice's identityDH2 = DH(EK_A, IK_B) — authenticates Bob's identityDH3 = DH(EK_A, SPK_B) — ephemeral forward secrecyDH4 = 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 |
Responder computation
Section titled “Responder computation”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
DH4completes, preserving forward secrecy.
Session setup output
Section titled “Session setup output”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 viaKDF_RK. The initiator’s ratchet is established immediately. - Responder — sets
SKas 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 againstSPK_B). The responder’s ratchet completes on the first received message, which triggers a DH ratchet step with a freshly generated key pair.
Security properties
Section titled “Security properties”| 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.
