We’re pleased to announce the PSQ protocol for establishing a hybrid post-quantum shared secret between two parties.
Cryptographic protocols, which exclusively rely on classical public key cryptography for establishing shared secrets, may be vulnerable to harvest-now-decrypt-later (HNDL) quantum attacks. However, many protocols in widespread use today allow injecting a previously established pre-shared key (PSK) into the computation of the shared secret. If we can establish this pre-shared key in a way that is secure against HNDL attackers, we can provide an easy way of protecting many applications without having to touch their internals.
The PSQ protocol serves exactly this purpose: for an initiator and responder to establish an HNDL-protected shared secret. We use a hybrid approach, combining Diffie-Hellman key exchange and post-quantum KEMs, so the protocol also remains secure against classical attackers in case the PQ-KEM turns out to fall short of its security goals even to a classical attacker.
PSQ is provided in the Rust crate libcrux-psq implementing the initiator and responder roles with an API inspired by the snow crate for Noise protocol sessions. This means you can use PSQ purely for the handshake and resulting post-quantum PSK, or you can use it directly as a drop-in replacement for Noise-based message exchange.
In this post we will demonstrate how the protocol can be used. Stay tuned for a follow-up on more details of the protocol and its security properties, or read more about that in the crate’s Readme already.
How to PSQ

The picture above shows how initiator and responder can perform the handshake and derive secure communication channels from the resulting shared session secret.
Creating the Participants
We can create a PSQ initiator and responder with the PrincipalBuilder1:
let mut initiator = PrincipalBuilder::new(rng)
.context(ctx)
.build_registration_initiator(initiator_ciphersuite)?;
let mut responder = PrincipalBuilder::new(rng)
.context(ctx)
.build_responder(responder_ciphersuite)?;
Here, the .context(ctx) call binds the eventually established PSQ session to an application-provided context, e.g. an identifier of the application where you want to use the PSQ shared secret.
The initiator_ciphersuite and responder_ciphersuite arguments select the concrete cryptographic building blocks used in the handshake, and provide the associated key material to the principals.
In terms of post-quantum KEMs PQS supports ML-KEM 768 and Classic McEliece 460896f right now. The initiator must provide the responder’s public keys, classic and pq, which ensures responder authentication.
The protocol also supports mutual authentication of the participants, where the initiator can be authenticated either via a long-term Diffie-Hellman public key or via an Ed25519 or ML-DSA 65 signature under a long-term signature verification key.2
An example ciphersuite configuration could look like this:
let initiator_ciphersuite = CiphersuiteBuilder::new(CiphersuiteName::X25519_MLKEM768_MLDSA65_CHACHA20POLY1305_HKDFSHA256)
.longterm_x25519_keys(&initiator_x25519_keys)
.peer_longterm_x25519_pk(&responder_x25519_public_key)
.peer_longterm_mlkem_pk(&responder_mlkem_public_key)
.build_initiator_ciphersuite()?;
let responder_ciphersuite = CiphersuiteBuilder::new(CiphersuiteName::X25519_MLKEM768_MLDSA65_CHACHA20POLY1305_HKDFSHA256)
.longterm_x25519_keys(&responder_x25519_keys)
.longterm_mlkem_encapsulation_key(&responder_mlkem_public_key)
.longterm_mlkem_decapsulation_key(&responder_mlkem_private_key)
.build_responder_ciphersuite()?;
Running the Handshake
Now, the two-message handshake can begin, allowing initiator and sender to exchange an optional payload each, along with the PSQ handshake messages:
// Initiator sends its first message
initiator.write_message(b"Initiator payload", &mut msg_channel)?;
{
// Responder reads the initiator message
responder.read_message(&msg_channel, &mut payload_buf_responder)?;
// Responder writes response
responder.write_message(b"Responder payload", &mut msg_channel)?;
}
// Initiator reads the responder message
initiator.read_message(&msg_channel, &mut payload_buf_initiator)?;
// Now the handshake is finished and a `Session` can be established
assert!(initiator.is_handshake_finished());
let initiator_session = initiator.into_session()?;
{
assert!(responder.is_handshake_finished());
let responder_session = responder.into_session()?;
}
After the handshake is finished, both sides can derive a Session that encapsulates the shared post-quantum secret and authenticates both parties involved in the handshake via the public key material supplied during the handshake.
How to use a PSQ Session
There are two ways to make use of a completed PSQ handshake:
- You can export a post-quantum secure pre-shared key derived from the session secret for use in any application that accepts pre-shared keys,
let mut exported_secret = [0u8; EXTERNAL_SECRET_LEN];
initiator_session.export_secret(b"some context for the external secret", &mut exported_secret)?;
- Both parties can derive a large number of independent secure transport channels managed by
libcrux-psqthat offer aread_message/write_messageAPI for sending messages back and forth, encrypted under keys derived from the main session secret.
let mut initiator_channel = initiator_session.transport_channel()?;
let mut responder_channel = responder_session.transport_channel()?;
initiator_channel.write_message(app_data_initiator, &mut msg_channel)?;
responder_channel.read_message(&msg_channel, &mut payload_buf_responder)?;
Get Quantum-Ready Now
If you want to try it out for yourself, you can get the latest release of libcrux-psq (v0.0.6 at time of writing) on crates.io, as well as the source code on our Github repository.
Migrating your cryptographic stack can be daunting. Reach out to us to discuss a PQC roadmap tailored to your infrastructure.
Stay tuned for a follow-up blog post on the details of the security analysis of the PSQ protocol!