X3DH for the browser. This is a typeScript implementation of X3DH, as described in Going Bark: A Furry's Guide to End-to-End Encryption.
This uses the Web Crypto API, so it works in the browser.
X3DH (Extended Triple Diffie-Hellman) is a secure key exchange protocol for end-to-end encrypted communication. It allows two parties to establish a shared secret for encrypted messaging, even when one party is offline.
This library handles key exchange only. It returns a shared secret that you can use with a ratcheting protocol (like Double Ratchet) for ongoing message encryption. No session state is stored — this does key exchange only.
The standard X3DH handshake usually includes three exchanges:
These three Diffie–Hellman results are then concatenated and passed through a KDF (key derivation function) to produce a single strong shared secret.
This is a fork of soatok/rawr-x3dh.
Thanks @soatok for working in public.
npm i -S @substrate-system/webcrypto-x3dh
This library implements the Extended Triple Diffie-Hellman key exchange, with a few minor tweaks:
This library is designed to work across different JavaScript environments:
The library now handles Ed25519 key format differences between browsers and Node.js automatically:
See Signal docs - 3.1. Overview
X3DH has three phases:
- Bob publishes his identity key and prekeys to a server.
- Alice fetches a "prekey bundle" from the server, and uses it to send an initial message to Bob.
- Bob receives and processes Alice's initial message.
Use @substrate-system/keys for identity key management.
Alice wants to start a conversation with Bob. Bob has already published some public keys to the server.
Every user has an identity key (IK) — a long-term, static ed25519 keypair.
At this point Bob should have published an identity key, a signed pre-key (SPK), and a one-time pre-key (OPK).
Alice must have an identity key and an ephemeral key (EK), which is created fresh for each session.
When all is said and done, both parties have the same shared secret key material.
Since messages are authenticated via ephemeral DH rather than digital signatures on each message, a third party can’t cryptographically prove you authored a message.
One-time pre-keys prevent an attacker from re-using an initial handshake to trick you into deriving the same session key again.
import { EccKeys } from '@substrate-system/keys/ecc'
import { X3DH } from '@substrate-system/webcrypto-x3dh'
// 1. Create identity keys with @substrate-system/keys
const aliceKeys = await EccKeys.create()
await aliceKeys.persist()
// 2. Generate X25519 pre-keys
const preKeyPair = await X3DH.prekeys()
// 3. Create X3DH keys instance
// - aliceKeys has privateWriteKey and publicWriteKey properties
// - preKeyPair has privateKey and publicKey properties
const x3dhKeys = X3DH.X3DHKeys(aliceKeys, preKeyPair)
// 4. Initialize X3DH with keys and identity string
const x3dh = new X3DH(x3dhKeys, aliceKeys.DID)
// 5. Generate one-time keys for key exchange
const oneTimeKeyBundle = await x3dh.generateOneTimeKeys(10)
// Upload oneTimeKeyBundle to your server so others can retrieve them
// during key exchange
// 6. Perform key exchange with another user
// First, fetch the recipient's public keys from your server
const recipientKeys = await fetch('/api/keys/recipient-did').then(r => r.json())
// recipientKeys has shape: {
// IdentityKey,
// SignedPreKey: { Signature, PreKey },
// OneTimeKey?
// }
const result = await x3dh.initSend('recipient-did', recipientKeys)
// Send the handshake to the recipient (via your server or direct messaging),
// so they can derive the same secret.
await sendToRecipient(result.handshakeData)
// X3DH is now complete. Use the shared secret to initialize your
// ratcheting protocol (e.g., Double Ratchet)
const doubleRatchet = new DoubleRatchet(result.sharedSecret)
The other side of the conversation above.
import { EccKeys } from '@substrate-system/keys/ecc'
import { X3DH } from '@substrate-system/webcrypto-x3dh'
// 1. Create identity keys with @substrate-system/keys
const bobKeys = await EccKeys.create()
await bobKeys.persist()
// 2. Generate X25519 pre-keys
const preKeyPair = await X3DH.prekeys()
// 3. Create X3DH keys instance
const x3dhKeys = X3DH.X3DHKeys(bobKeys, preKeyPair)
// 4. Initialize X3DH with keys and identity string
const x3dh = new X3DH(x3dhKeys, bobKeys.DID)
// 5. Generate one-time keys for key exchange
const oneTimeKeyBundle = await x3dh.generateOneTimeKeys(10)
// Upload oneTimeKeyBundle to your server so others can retrieve them
// during key exchange
// 6. Receive handshake data from Alice
const handshakeData = await receiveFromSender() // Get handshake from Alice
const result = await x3dh.initReceive(handshakeData)
// X3DH is now complete. Both sides have the same shared secret.
//
// result.sharedSecret is a Uint8Array - the same value Alice has
// result.senderIdentity is Alice's DID
// Use the shared secret to initialize your ratcheting protocol
// (e.g., Double Ratchet)
const doubleRatchet = new DoubleRatchet(result.sharedSecret)
static X3DHKeysclass X3DH {
static X3DHKeys (
idKeys:{
privateWriteKey:Ed25519SecretKey,
publicWriteKey:Ed25519PublicKey
},
preKeypair:{
privateKey:X25519SecretKey,
publicKey:X25519PublicKey
}
):X3DHKeys
}
type X3DHKeystype X3DHKeys = {
identitySecret:Ed25519SecretKey
identityPublic:Ed25519PublicKey
preKeySecret:X25519SecretKey
preKeyPublic:X25519PublicKey
}
const x3dh = new X3DH(
x3dhKeys, // X3DHKeys (required)
identityString, // string/DID (required)
keyDerivationFunction // KeyDerivationFunction (optional)
)
Once your X3DH object has been created, you can perform key exchanges:
// Generate one-time keys for others to use
const oneTimeKeyBundle = await x3dh.generateOneTimeKeys(10)
// Upload oneTimeKeyBundle to your server so others can retrieve them
// during key exchange
// Initiate communication (sender side)
// First, fetch the recipient's public keys from your server
const recipientKeys = await fetch('/api/keys/recipient-did-string')
.then(r => r.json())
const result = await x3dh.initSend('recipient-did-string', recipientKeys)
// result.sharedSecret is a Uint8Array you use to initialize your
// ratcheting protocol
// result.handshakeData is sent to the recipient
The recipientKeys parameter should be an object containing the recipient's
identity key, signed pre-key, and optional one-time key.
See the definition of the InitServerInfo type in src/index.ts:
type InitServerInfo = {
IdentityKey:string;
SignedPreKey:{
Signature:string;
PreKey:string;
};
OneTimeKey?:string;
};
Derive the same secret value as the sender.
// Receive handshake (recipient side)
const result = await x3dh.initReceive(handshakeData)
// X3DH is now complete. Both sides have the same shared secret.
// result.sharedSecret is a Uint8Array - the same value the sender has
// result.senderIdentity is the sender's DID
// Use the shared secret to initialize your ratcheting protocol
// (e.g., Double Ratchet)
const doubleRatchet = new DoubleRatchet(result.sharedSecret)
This library only performs the X3DH key exchange. For ongoing message
encryption, you need to implement or use a ratcheting protocol like
Double Ratchet with the shared secret returned from
initSend() and initReceive().
This library does not implement Gossamer integration for identity key management.
type X3DHKeys = {
identitySecret: Ed25519SecretKey
identityPublic: Ed25519PublicKey
preKeySecret: X25519SecretKey
preKeyPublic: X25519PublicKey
}
type InitServerInfo = {
IdentityKey: string;
SignedPreKey: {
Signature: string;
PreKey: string;
};
OneTimeKey?: string;
}
type InitSenderInfo = {
Sender: string,
IdentityKey: string,
PreKey: string,
EphemeralKey: string,
OneTimeKey?: string
}
type InitSendResult = {
sharedSecret: Uint8Array
handshakeData: InitSenderInfo
}
type InitReceiveResult = {
sharedSecret: Uint8Array
senderIdentity: string
}
X3DH - Main class for X3DH key exchange operationsMain class for performing X3DH key exchanges.
Static Methods:
X3DH.prekeysX3DH.prekeys():Promise<CryptoKeyPair>
Generate an X25519 key pair for use as pre-keys in X3DH.
Returns: A promise resolving to a CryptoKeyPair with
privateKey and publicKey.
Example:
const preKeyPair = await X3DH.prekeys()
X3DH.X3DHKeysX3DH.X3DHKeys(
idKeys:{
privateWriteKey:Ed25519SecretKey,
publicWriteKey:Ed25519PublicKey
},
preKeypair:{
privateKey:X25519SecretKey,
publicKey:X25519PublicKey
}
):X3DHKeys
Helper function to construct an X3DHKeys object from identity keys and pre-keys.
Parameters:
idKeys - Object with privateWriteKey and publicWriteKey
(Ed25519 keys from @substrate-system/keys)preKeypair - Object with privateKey and publicKey
(X25519 keys from X3DH.prekeys())Returns: X3DHKeys object for use in X3DH constructor
Example:
const x3dhKeys = X3DH.X3DHKeys(aliceKeys, preKeyPair)
Constructor:
new X3DHnew X3DH(
keys:{
identitySecret:Ed25519SecretKey
identityPublic:Ed25519PublicKey
preKeySecret:X25519SecretKey
preKeyPublic:X25519PublicKey
},
identityString:string,
kdf?:KeyDerivationFunction
)
Creates a new X3DH instance.
Parameters:
keys: X3DHKeys (required) - Identity and pre-keys for this participantidentityString: string (required) - Unique identifier (typically a DID)
for this participantkdf?: KeyDerivationFunction (optional) - Custom key derivation function
(defaults to blakeKdf)Example:
const x3dh = new X3DH(x3dhKeys, aliceKeys.DID)
Instance Methods:
generateOneTimeKeysgenerateOneTimeKeys(numKeys?:number):Promise<{
signature:string
bundle:string[]
}>
Generates and signs a bundle of one-time keys for use in X3DH handshakes.
Stores them locally for later retrieval during initReceive().
Parameters:
numKeys?:number (default: 100) - Number of one-time keys to generateReturns: Promise resolving to SignedBundle with:
signature:string - Hex-encoded signature over the bundlebundle:string[] - Array of hex-encoded public keysExample:
const bundle = await x3dh.generateOneTimeKeys(10)
// Upload bundle to server for distribution
initSendInitiates X3DH key exchange as the sender. Performs X3DH handshake and returns the shared secret.
initSend(
recipientIdentity:string,
recipientKeys:{
IdentityKey:string
SignedPreKey:{
Signature:string
PreKey:string
}
OneTimeKey?:string
}
):Promise<{
sharedSecret: Uint8Array
handshakeData: InitSenderInfo
}>
Parameters:
recipientIdentity:string - DID or identifier of the recipientrecipientKeys:InitServerInfo - Object containing recipient's public keysReturns: Promise resolving to InitSendResult containing:
sharedSecret: Uint8Array - The derived shared secret from X3DHhandshakeData: InitSenderInfo - Handshake data to send to recipientExample:
// Fetch recipient's keys from your server
const recipientKeys = await fetch('/api/keys/did:example:bob').then(r => r.json())
const result = await x3dh.initSend('did:example:bob', recipientKeys)
// result.sharedSecret is a Uint8Array to use for your ratcheting protocol
// Send result.handshakeData to the recipient
initReceiveReceive and process an initial handshake message from a sender. Returns the shared secret and sender's identity.
initReceive(req:{
Sender:string
IdentityKey:string
PreKey:string
EphemeralKey:string
OneTimeKey?:string
}):Promise<{
sharedSecret: Uint8Array
senderIdentity: string
}>
Parameters:
req:InitSenderInfo - Handshake data received from senderReturns: Promise resolving to InitReceiveResult containing:
sharedSecret: Uint8Array - The derived shared secret from X3DHsenderIdentity: string - The sender's DIDExample:
const result = await x3dh.initReceive(handshakeData)
console.log(`Received from ${result.senderIdentity}`)
// Use result.sharedSecret to initialize your ratcheting protocol
signPreKeySign an X25519 pre-key with an Ed25519 identity key. Use to create signed pre-key bundles.
signPreKey(signingKey:Ed25519SecretKey, preKey:X25519PublicKey):Promise<string>
Parameters:
signingKey: Ed25519SecretKey - Identity secret key for signingpreKey: X25519PublicKey - Pre-key public key to signReturns: hex-encoded signature
setIdentityStringUpdate the identity string for this X3DH instance.
setIdentityString(id:string):void
Parameters:
id:string - New identity string (DID)Don't use it in production until version 1.0.0 has been tagged. The API can break at any moment until that happens.
However, feel free to test and play with it.