Hi everyone!
I've written an article and I'm publishing it prematurely. Initially, I planned to write it after the project was completed, but since there are still a couple of months left until the end, I decided not to waste time and write the article while the information is still fresh in my mind. Besides, I'm mostly writing this for myself. :) In one of my latest projects, which I'm developing as open source, I implemented end-to-end encryption—similar to how WhatsApp or Telegram do it, for example.
In this article, we'll dive into the implementation of client-side message encryption using JavaScript and the Web Crypto API, breaking down a practical example that will be at the very end of the article.
Let's start by saying that if you're a complete beginner in cryptography, understanding what's written here might not be easy. Even with 10 years of development experience, I had to scratch my head a bit—everything happening here is pure mathematics, which we won't be discussing in this article :) The easily impressed might think it's magic :)
If I were to briefly explain the essence of end-to-end encryption without complex words and terms:
The Magic of Encryption in Three Keys

Three keys are the foundation upon which end-to-end encryption is built. Pay attention to the key in the center – this is important.
The foundation on which everything rests is these three keys. Refer back to this section if something is unclear.
- Private Key: Stored (in encrypted form).
- Public Key: Accessible to everyone.
- Shared Secret / Symmetric Key: Generated based on your private key + your contact's public key. This is the key used for the direct encryption and decryption of messages.
The combination of your private key + your contact's public key allows you to obtain the shared secret key (in our example below, this will be an AES key). Thanks to this shared secret key, you can encrypt and decrypt messages.
Private and public keys can be stored in a database, but there's a nuance with the private key. The private key itself is not recommended to be stored in plain text; it needs to be additionally encrypted with the user's password or any other keyword (within a messenger, this is typically the user's password). We store the public key in plain text.
The shared secret key (the one referred to as this.aesKey in the code below) is not stored in the database. It is generated (calculated) each time a chat with a specific contact is initialized. This might cause confusion: how will we decrypt messages if this key is not stored but generated anew? This is where the "magic" of asymmetric encryption and key exchange protocols lies.
The shared key is "Your private key" + "Contact's public key."
When you open a chat with a contact, your client recalculates this shared secret key, as you already know, like this: (Your private key + Your contact's public key = Shared secret key). With this key, you encrypt new messages and decrypt all previous messages in this chat, as they were encrypted with the same shared secret key. You can wrack your brains for a long time to no avail until you understand asymmetric encryption and key exchange protocols.
Asymmetric Encryption and ECDH
Asymmetric encryption uses a pair of keys: public and private. The public key can be freely distributed, while the private key must be kept secret by its owner, meaning in an encrypted form.
ECDH (Elliptic Curve Diffie-Hellman) is a key exchange protocol based on elliptic curve mathematics. It allows two parties, each possessing their own ECDH key pair (private and public), to establish a shared secret key over an insecure channel. Importantly, a third party, even if they intercept their public keys, cannot compute this shared secret. Our example uses the P-256 curve – a popular and reliable standard.
I think few understood what they just read. All you need to understand at this stage is that the technology works :) Later, the puzzle will come together, perhaps after a reread. And now, a bit about built-in browser technologies.
Web Crypto API
The Web Crypto API is a JavaScript interface built into browsers that provides access to low-level cryptographic primitives. It allows performing operations such as hashing, signature generation, encryption, and decryption. Using the Web Crypto API is preferable to third-party libraries for basic cryptographic operations, as it is often hardware-accelerated and thoroughly vetted for security. All Web Crypto API operations are asynchronous and return a Promise.
Now let's move on to the practical analysis. I've created a ChatCrypto class, which we'll examine in more detail:
class ChatCrypto {
constructor(myPrivateKeyBase64, theirPublicKeyBase64) {
this.myPrivateKeyBase64 = myPrivateKeyBase64;
this.theirPublicKeyBase64 = theirPublicKeyBase64;
this.aesKey = null; // The shared symmetric AES key will be stored here
}
static base64ToArrayBuffer(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
static arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let b of bytes) {
binary += String.fromCharCode(b);
}
return btoa(binary);
}
init() {
// Convert keys from Base64 to ArrayBuffer
const privateRaw = ChatCrypto.base64ToArrayBuffer(this.myPrivateKeyBase64);
const publicRaw = ChatCrypto.base64ToArrayBuffer(this.theirPublicKeyBase64);
// Note: The following lines for parsing publicRaw into x and y coordinates,
// and assembling uncompressedPoint might be specific to a particular format
// for representing a "raw" public key. If publicRaw is already in SPKI format,
// they might not be necessary, as crypto.subtle.importKey("spki", ...)
// expects a standard structure.
// const x = publicRaw.slice(0, publicRaw.byteLength / 2);
// const y = publicRaw.slice(publicRaw.byteLength / 2);
// const uncompressedPoint = new Uint8Array([0x04, ...new Uint8Array(x), ...new Uint8Array(y)]);
// Import our private key
return crypto.subtle.importKey(
"pkcs8", // Private key format (standard)
privateRaw,
{ name: "ECDH", namedCurve: "P-256" }, // Algorithm and parameters
false, // Non-exportable
["deriveBits"] // Allowed usage: for deriving bits (shared secret)
).then(privateKey => {
// Import the contact's public key
return crypto.subtle.importKey(
"spki", // Public key format (standard)
publicRaw,
{ name: "ECDH", namedCurve: "P-256" },
false, // Non-exportable
[] // Specific uses are not needed here for the public key in ECDH
).then(publicKey => {
// 4. Compute the shared secret (deriveBits)
return crypto.subtle.deriveBits(
{ name: "ECDH", public: publicKey }, // Specify the contact's public key
privateKey, // Our private key
256 // Length of the derived secret in bits
);
});
}).then(sharedBits => {
// Hash the shared secret to obtain an AES key (using SHA-256 as KDF)
return crypto.subtle.digest("SHA-256", sharedBits);
}).then(hashed => {
// Import the hashed secret as an AES-GCM key
return crypto.subtle.importKey(
"raw", // "Raw" byte format
hashed, // Hashed secret
{ name: "AES-GCM" }, // Symmetric encryption algorithm
false, // Non-exportable
["encrypt", "decrypt"] // Allowed uses: encryption and decryption
);
}).then(aesKey => {
this.aesKey = aesKey; // Save the obtained AES key
return true; // Signal successful initialization
});
}
encrypt(plaintext) {
if (!this.aesKey) return Promise.reject("ChatCrypto not initialized");
// Generate a unique initialization vector (IV)
const iv = crypto.getRandomValues(new Uint8Array(12)); // 12 bytes (96 bits) is recommended for AES-GCM
// Convert the text message to bytes (UTF-8)
const encoded = new TextEncoder().encode(plaintext);
// Encrypt the data
return crypto.subtle.encrypt(
{ name: "AES-GCM", iv: iv }, // Algorithm and IV
this.aesKey, // Our shared AES key
encoded // Data to encrypt
).then(encrypted => {
// 4. Return IV and encrypted data (in Base64 for convenient transmission)
return {
iv: ChatCrypto.arrayBufferToBase64(iv),
data: ChatCrypto.arrayBufferToBase64(encrypted)
};
});
}
decrypt(cipherBase64, ivBase64) {
if (!this.aesKey) return Promise.reject("ChatCrypto not initialized");
// Convert ciphertext and IV from Base64 to ArrayBuffer
const encrypted = ChatCrypto.base64ToArrayBuffer(cipherBase64);
const ivBuffer = ChatCrypto.base64ToArrayBuffer(ivBase64);
// Decrypt the data
return crypto.subtle.decrypt(
{ name: "AES-GCM", iv: new Uint8Array(ivBuffer) }, // Algorithm and IV (must be a TypedArray)
this.aesKey, // The same shared AES key
encrypted // Encrypted data
).then(decrypted => {
// Convert decrypted bytes back to a string
return new TextDecoder().decode(decrypted);
});
}
}
constructor() and base64ToArrayBuffer() method:
The constructor accepts your private key and the contact's public key in Base64 format. Base64 is a way of encoding binary data into a text string, convenient for transmission or storage.
this.aesKey is initialized as null and will be populated after the init() method successfully completes.
The static methods base64ToArrayBuffer and arrayBufferToBase64 are used to convert data between Base64 strings and ArrayBuffer (the format Web Crypto API works with).
init() Method: Establishing the Shared AES Key
This is the heart of our class, where the "magic" of ECDH occurs and the shared key for symmetric encryption is created.
Breakdown of steps in init():
Key Conversion: Keys are converted from Base64 to ArrayBuffer.
Import Private Key: Your private key is imported in pkcs8 format. It's specified that this is an ECDH key on the P-256 curve and will be used for deriveBits (computing the shared secret).
Import Contact's Public Key: The contact's public key is imported in spki format.
Compute Shared Secret (deriveBits): This is the key ECDH step. Using your private key and the contact's public key, deriveBits computes a shared secret set of bits (sharedBits). This secret will be the same for you and your contact if they use their respective private keys and each other's public keys.
Hash Shared Secret (digest): sharedBits are hashed using SHA-256. This is a common practice to transform the output of deriveBits into a cryptographically strong key of the desired length for a symmetric cipher (AES in this case). This step also serves as a KDF (Key Derivation Function).
Import AES Key (importKey): The resulting hash (hashed) is imported as a "raw" key for the AES-GCM algorithm. This key (this.aesKey) is now ready to be used for encrypting and decrypting messages.
After successful execution, this.aesKey will contain a CryptoKey object, ready for use.
encrypt(plaintext) Method: Encrypting a Message
Breakdown of steps in encrypt():
Generate IV (Initialization Vector): A random 12-byte IV is created. As a reminder, it must be unique for each encryption with the same key.
Encode Text: The message is converted from a JavaScript string to a Uint8Array (a sequence of bytes in UTF-8 encoding) using TextEncoder.
Encryption: crypto.subtle.encrypt performs data encryption using AES-GCM, our this.aesKey, and the generated iv.
Return Result: The encrypted data and IV (both in Base64) are returned as an object. The IV must be transmitted to the recipient along with the ciphertext, as it will be required for decryption.
decrypt(cipherBase64, ivBase64) Method: Decrypting a Message
Breakdown of steps in decrypt():
Data Conversion: The received ciphertext and IV (in Base64) are converted back to ArrayBuffer. Note that for crypto.subtle.decrypt, the iv parameter must be a TypedArray (e.g., Uint8Array), so we pass new Uint8Array(ivBuffer).
Decryption: crypto.subtle.decrypt performs the decryption. Importantly, AES-GCM will not only decrypt the data but also verify its integrity and authenticity, using the same aesKey and iv that were used for encryption. If the data has been tampered with, the key is wrong, or the IV is wrong, the decrypt method will return an error (reject the Promise).
Decode Text: Successfully decrypted bytes are converted back into a readable string using TextDecoder.
- How It Works Together: Conceptual Flow Key Pair Generation:
User A generates their ECDH key pair (public PkA and private SkA).
User B does the same (public PkB and private SkB). This step is not shown in the provided ChatCrypto code but is shown below in the examples section (it is performed once, for example, during user registration), and it precedes the use of the class. The Web Crypto API has a crypto.subtle.generateKey method for this. The private key Sk must be stored securely and encrypted with the user's password.
Public Key Exchange:
User A transmits their public key PkA to User B.
User B transmits their public key PkB to User A. This exchange must be secure to avoid Man-in-the-Middle (MitM) attacks. For example, via a secure server or by verifying key fingerprints.
ChatCrypto Initialization and Shared Secret Key Computation:
On User A's side: const cryptoA = new ChatCrypto(SkA_base64, PkB_base64); await cryptoA.init();
On User B's side: const cryptoB = new ChatCrypto(SkB_base64, PkA_base64); await cryptoB.init(); As a result, both (cryptoA.aesKey and cryptoB.aesKey) will have computed the same symmetric AES key.
Message Exchange:
User A encrypts a message for B: const { iv, data } = await chatCryptoA.encrypt("Hello, B!"); Then A sends the { iv, data } object to user B.
User B receives { iv, data } and decrypts: const message = await chatCryptoB.decrypt(data, iv); // message will be "Hello, B!"
Example: Encrypting Text encrypt()
// Initialize the class
let chatCrypto = new ChatCrypto( "My_private_key" , "Contact_public_key" );
// Run it
chatCrypto.init().then(() => {
chatCrypto.encrypt("Text").then(result => {
// The encrypted text will be output
console.log(result);
});
});
Example: Decrypting Text decrypt()
// Initialize the class
let chatCrypto = new ChatCrypto( "My_private_key" , "Contact_public_key" );
// Run it
chatCrypto.init()
.then( () => chatCrypto.decrypt("Encrypted_text", "Vector_key_iv") ) // Note: "Vector_key" likely means IV
.then(result => {
// The decrypted text will be output
console.log(result);
})
Conclusion
We've looked at how to implement robust end-to-end message encryption in JavaScript using the Web Crypto API. The combination of ECDH for secure key exchange and AES-GCM for efficient and authenticated data encryption is a powerful and modern approach. The ChatCrypto class serves as a good starting example of such an implementation. Remember the importance of secure generation, storage of private keys, and reliable exchange of public keys to build a secure system.
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.