The Need for Post-Quantum Security in Transit

Up until the last few years, pretty much all web traffic was transmitted over channels that rely on elliptic curve cryptography (ECC) for key agreement. As ECC is quantum-vulnerable, this means that all of this web traffic may be subject to harvest now, decrypt later attacks. Thankfully, a hybrid key agreement scheme which utilizes both ECC and post-quantum methods can now be used in TLS 1.3. This key agreement method combines the typical X25519 Diffie–Hellman key exchange with the post-quantum key encapsulation method ML-KEM, resulting in the name X25519MLKEM768.

The simple approach to support post-quantum encryption for your web application is to upgrade your server’s TLS library to a version that supports X25519MLKEM768. Then you must hope that your users have secure, up-to-date browsers that have done the same upgrade. However, Cloudflare Radar’s latest reports show that only 37% of requests to Cloudflare are currently using X25519MLKEM768. So, taking this approach at present will secure less than half of your users.

Cloudflare Radar plot of the percentage of HTTP requests to Cloudflare over the past year that have been post-quantum-encrypted (light-blue corresponding to post-quantum encrypted)

Cloudflare Radar plot of the percentage of HTTP requests to Cloudflare over the past year that have been post-quantum-encrypted (light-blue corresponding to post-quantum encrypted)

So, what if you want to guarantee post-quantum encryption in transit for all of your users?

Our Solution: ML-KEM over WebSockets

While building yellowpages, we wanted to guarantee post-quantum encryption for all of our users, while still allowing our application to be accessible from any browser. To achieve this, we decided to implement our own ML-KEM handshake over WebSockets. The initial connection is made from the browser to the server using a typical wss:// request, which results in TLS encryption, and then we do a further ML-KEM handshake at the application layer.

High-level flow of a WSS connection followed by an ML-KEM handshake

High-level flow of a WSS connection followed by an ML-KEM handshake


This guarantees hybrid post-quantum security, as we know that the TLS connection will encrypt the connection using at least ECC+AES, and that our client will further encrypt sensitive data using ML-KEM+AES. If an attacker harvests the data transmitted in our connections, they would need to break both ECC and ML-KEM to decrypt the sensitive data. We believe that a quantum attacker will someday be able strip away the ECC layer of this encryption, but there is no known quantum attack that could break through the ML-KEM layer.


The hybrid post-quantum encryption provided by our solution

The hybrid post-quantum encryption provided by our solution


Choosing Parameters

Before beginning the implementation, we needed to choose the parameter sets for ML-KEM and AES. ML-KEM has three parameter sets: 512, 768, and 1024. We opted for ML-KEM-768, because we want to prioritize a high degree of security (192 bits), and the parameters have a negligible impact on our application’s performance. TLS also opted for ML-KEM-768, which gives us some added peace of mind.


The three parameter sets of ML-KEM, from FIPS-203

The three parameter sets of ML-KEM, from FIPS-203



For AES, we chose the standard parameters of AES-GCM-256. AES may be subject to Grover’s algorithm eventually, which could bring the security of AES-256 down from 256 bits to 128 bits. We deem 128 bits of security sufficient, and for Grover’s to be much further away than the more imminent quantum threat of Shor’s algorithm.

Implementation

Time to dig into our open-source production code for the ML-KEM handshake over WebSockets. We use a typical browser-based JavaScript client and a Rust server that runs in an AWS Nitro Enclave. This section will be light on prose, as we’ll let the code speak for itself.


⚠️ Warning: Rolling your own crypto

This is a custom cryptography implementation that we needed to do for yellowpages. If you don’t need to guarantee post-quantum security, you’ll likely be better off taking the simple approach mentioned earlier – use the latest version of TLS, and hope that your users do the same. But if you want to take a custom approach like us, you should also get it audited to avoid implementation errors. We had ours audited by cure53.


Server: Host a WSS WebSockets endpoint

We use Axum for our Rust server, and they have excellent WebSocket support. We use Evervault Enclaves to host our server, and we use their TLS termination feature.

#[tokio::main]
async fn main() {
...
let app = Router::new()
.route("/prove", get(handle_ws_upgrade))
...
...
}
pub async fn handle_ws_upgrade(
State(config): State<Config>,
...
ws: WebSocketUpgrade,
...
) -> impl IntoResponse {
log::info!("Received WebSocket upgrade request");
...
ws.on_upgrade(move |socket| run_pq_channel_protocol(socket, config))
}

Link to source code

Client: Establish WebSocket Connection with Server

We use the standard JavaScript WebSocket client.

const ws = new WebSocket(
`${domains.proofService}/prove?...`
);

Link to source code

Client: Generates ML-KEM Keypair

The client generates the ML-KEM keypair, using a trusted source of entropy. For ML-KEM-768, this keypair has a 1184 byte encapsulation key (public key) and a 2400 byte decapsulation key (private key). We chose noble-post-quantum for our JavaScript post-quantum cryptography, because noble libraries are the go-to for JavaScript Web3 projects, and there are currently no audited JavaScript libraries for post-quantum.

/// api.ts
mlKem768Keypair = generateMlKem768Keypair();
/// cryptography.ts
import { ml_kem768 } from '@noble/post-quantum/ml-kem';
...
function generateMlKem768Keypair(): MlKem768Keypair {
try {
const keyPair = ml_kem768.keygen();
return {
encapsulationKey: keyPair.publicKey,
decapsulationKey: keyPair.secretKey
};
} catch (error) {
...
}
}

Links to source code: api code, cryptography code

Client: Transmit Encapsulation Key to Server

We base64-encode the encapsulation key, and transmit it in a JSON message over the WebSocket connection.

const mlKem768EncapsulationKeyBase64 = base64.encode(
mlKem768Keypair.encapsulationKey
);
// Step 3: Send handshake with ML-KEM-768 public key
const handshakeMessage: HandshakeMessage = {
ml_kem_768_encapsulation_key: mlKem768EncapsulationKeyBase64
};
ws.send(JSON.stringify(handshakeMessage));

Link to source code

Server: Run Encapsulate Function

The server parses the received data and runs the ML-KEM encapsulate function. This function generates the 32 byte secret key which will be shared by both parties, and encrypts it using the provided encapsulation key. In ML-KEM-768, this produces a 1088 byte ciphertext. We chose RustCrypto’s ml_kem library for our Rust server, as there is currently no audited Rust ml_kem library, and we have good trust in RustCrypto’s implementation.

pub async fn run_pq_channel_protocol(mut socket: WebSocket, config: Config) {
log::info!("WebSocket connection established");
// Step 1: Perform handshake and get the shared secret
let shared_secret = match perform_handshake(&mut socket).await {
Ok(secret) => secret,
...
};
...
}
async fn perform_handshake(socket: &mut WebSocket) -> Result<SharedKey<MlKem768>, WsCloseCode> {
// Wait for message with a timeout
let receive_result = with_timeout!(HANDSHAKE_TIMEOUT_SECS, socket.recv(), "Handshake message");
...
// Parse handshake message
let handshake_request: HandshakeMessage = ok_or_bad_request!(
serde_json::from_str(&handshake_text),
"Failed to parse handshake message JSON"
);
...
// Decode the base64 encapsulation key from the client
let encapsulation_key_bytes = ok_or_bad_request!(
base64.decode(&handshake_request.ml_kem_768_encapsulation_key),
"Failed to decode base64 encapsulation key"
);
...
// Generate the shared secret and ciphertext
let mut rng = StdRng::from_entropy();
let Ok((ciphertext, shared_secret)): Result<(Ciphertext<MlKem768>, SharedKey<MlKem768>), _> =
encapsulation_key.encapsulate(&mut rng)
else {
...
};
...
}

Link to source code


🔐 At this point, the server has the ML-KEM-agreed secret key which will be possessed by both parties, and no one else.


Server: Transmit the Ciphertext back to the Client

We base64-encode the ciphertext, and transmit it back to the client in JSON over the WebSocket.

async fn perform_handshake(socket: &mut WebSocket) -> Result<SharedKey<MlKem768>, WsCloseCode> {
...
// Encode the ciphertext to base64
let ciphertext_base64 = base64.encode(ciphertext);
// Create and send the response
let handshake_response = HandshakeResponse {
ml_kem_768_ciphertext: ciphertext_base64,
auth_attestation_doc,
};
...
ok_or_internal_error!(
socket.send(WsMessage::Text(response_json.into())).await,
"Failed to send handshake response"
);
...
}

Link to source code

Client: Decapsulate the Ciphertext

The client parses the received ciphertext, and runs the ML-KEM decapsulate function on it using the decapsulation key. This decrypts the ciphertext to uncover the shared secret.

/// api.ts
const handshakeResponse = await raceWithTimeout<HandshakeResponse>(...);
// Step 5: Validate and decode the ciphertext
const mlKem768CiphertextBase64 = handshakeResponse.ml_kem_768_ciphertext;
...
// Decode base64 to bytes
const mlKem768CiphertextBytes = base64.decode(
mlKem768CiphertextBase64
) as MlKem768CiphertextBytes;
/// cryptography.ts
mlKemSharedSecret = deriveMlKem768SharedSecret(
mlKem768CiphertextBytes,
mlKem768Keypair
);
...
function deriveMlKem768SharedSecret(
ciphertextBytes: MlKem768CiphertextBytes,
keypair: MlKem768Keypair
): Uint8Array {
...
try {
...
// Derive the shared secret
sharedSecret = ml_kem768.decapsulate(
ciphertextBytes,
keypair.decapsulationKey
);
...
return sharedSecret;
}
...
}

Links to source code: api.ts, cryptography.ts



🔐 At this point, the client also has the ML-KEM-agreed secret key


Client: AES-Encrypt All Sensitive Data Using Shared Secret Key

From now on, the client is safe to transmit sensitive data to the server, provided they AES-encrypt the data using the agreed secret key. We chose to use noble-ciphers for AES in JavaScript, which has been audited.

/// api.ts
const aes256GcmEncryptedMessage = encryptProofRequestData(
proofRequestBytes,
mlKem768Keypair,
mlKem768CiphertextBytes
);
...
// Send the encrypted proof request as a binary message
ws.send(aes256GcmEncryptedMessage);
/// cryptography.ts
function encryptProofRequestData(
requestBytes: ProofRequestBytes,
mlKem768Keypair: MlKem768Keypair,
mlKem768CiphertextBytes: MlKem768CiphertextBytes
): Uint8Array {
...
try {
// Derive the shared secret
mlKemSharedSecret = deriveMlKem768SharedSecret(
mlKem768CiphertextBytes,
mlKem768Keypair
);
...
// Generate a random 96-bit (12-byte) nonce for AES-256-GCM
aes256GcmNonce = randomBytes(AES_256_GCM_NONCE_SIZE);
// Encrypt using AES-256-GCM with the ML-KEM derived shared secret as key
const aes256Gcm = gcm(mlKemSharedSecret, aes256GcmNonce);
const aes256GcmCiphertext = aes256Gcm.encrypt(requestBytes);
// Combine nonce and encrypted data into a single buffer
// Format: [12 bytes nonce][N bytes ciphertext]
const aes256GcmEncryptedMessage = new Uint8Array(
AES_256_GCM_NONCE_SIZE + aes256GcmCiphertext.length
);
aes256GcmEncryptedMessage.set(aes256GcmNonce);
aes256GcmEncryptedMessage.set(aes256GcmCiphertext, AES_256_GCM_NONCE_SIZE);
return aes256GcmEncryptedMessage;
}
...
}

Links to source code: api.ts, cryptography.ts

Server: AES-decrypt Sensitive Data using Shared Secret Key

The server can use AES-256-GCM to decrypt the data transmitted by the client. We chose to use RustCrypto’s aes-gcm crate, which has been audited.

pub async fn run_pq_channel_protocol(mut socket: WebSocket, config: Config) {
...
// Step 2: Receive the proof request
let proof_request = match receive_proof_request(&mut socket, shared_secret).await {
Ok(request) => request,
...
};
...
}
...
async fn receive_proof_request(
socket: &mut WebSocket,
shared_secret: SharedKey<MlKem768>,
) -> Result<ProofRequest, WsCloseCode> {
// Wait for message with a timeout
let receive_result = with_timeout!(PROOF_REQUEST_TIMEOUT_SECS, socket.recv(), "Proof request");
...
// Extract nonce and ciphertext
let (aes_256_gcm_nonce_bytes, aes_256_gcm_ciphertext) =
aes_256_gcm_encrypted_data.split_at(AES_GCM_NONCE_LENGTH);
let aes_256_gcm_nonce = Aes256GcmNonce::from_slice(aes_256_gcm_nonce_bytes);
...
// Create AES-GCM cipher using the shared secret
let aes_256_gcm_key = Aes256GcmKey::<Aes256Gcm>::from_slice(&shared_secret);
let aes_256_gcm_cipher = Aes256Gcm::new(aes_256_gcm_key);
// Decrypt the data
let decrypted_bytes = ok_or_bad_request!(
aes_256_gcm_cipher.decrypt(aes_256_gcm_nonce, aes_256_gcm_ciphertext),
"Failed to decrypt proof request"
);
...
}

Link to source code

Conclusion

This guarantees post-quantum security in transit for all yellowpages users! If you want to try it out, you can register to yellowpages and open Firefox’s network tab to view the WebSocket messages.

Here’s the full flow from start to finish:

Full flow diagram for ML-KEM over WebSockets

Full flow diagram for ML-KEM over WebSockets