A Rust crate that combines multiple post-quantum cryptographic techniques to facilitate quantum-resistant end-to-end encrypted messaging. pq-msg serves as an abstraction layer over various cryptographic schemes to provide a comprehensive solution for secure communication in a post-quantum world. It is written entirely in safe, pure Rust (#![forbid(unsafe_code)]).
| Component | Implementation | Purpose |
|---|---|---|
| Key Exchange | ML-KEM-1024 (FIPS 203) via ml-kem |
Quantum-resistant key establishment |
| Key Derivation | HKDF-SHA256 | One key chain per direction, a fresh key per message |
| Symmetric Encryption | XChaCha20Poly1305 | Fast and secure data encryption |
| Message Authentication | FN-DSA-1024 (Falcon, draft FIPS 206) via fn-dsa |
Quantum-resistant digital signatures |
This library is experimental and has not been independently audited. Do not use it in production.
- ML-KEM: the
ml-kemcrate states that its implementation "has never been independently audited! USE AT YOUR OWN RISK!" - FN-DSA: FIPS 206 has not been published yet. The
fn-dsacrate implements a best guess at the draft and warns that its keys and signatures may not interoperate with the final standard. When FIPS 206 is published,pq-msgwill release another breaking version, and signing keys and stored sessions will need to be regenerated.
use pq_msg::{
exchange::{pair::KEMPair, prekey::SignedPrekey},
messaging::{MessageSession, create_nonce, gen_session_id},
signatures::keypair::{SignerPair, ViewOperations},
};
fn main() {
let alice_signer = SignerPair::create();
let mut bob_signer = SignerPair::create();
// Bob creates a one-time prekey and signs its public part, which he shares with Alice
let bob_prekey = KEMPair::create();
let bob_signed_prekey = SignedPrekey::new(&mut bob_signer, &bob_prekey).unwrap();
// Create a base nonce with a new session id, and a counter of 0
let base_nonce = create_nonce(&gen_session_id(), 0);
// Lets create the message session for Alice first. This checks that the prekey
// was signed by Bob.
let (mut alice_session, ciphertext) = MessageSession::new_initiator(
alice_signer.clone(),
base_nonce,
&bob_signed_prekey,
bob_signer.pub_key_bytes(), // Bob's public signer key
)
.unwrap();
// Now for Bob it would look like this
let mut bob_session = MessageSession::new_responder(
&bob_prekey,
bob_signer.clone(),
base_nonce,
&ciphertext,
alice_signer.pub_key_bytes(), // Alice's public signer key
)
.unwrap();
// The prekey was one-time, so Bob deletes it now (dropping it wipes it from memory)
drop(bob_prekey);
// Both sessions now hold one chain key per direction, derived from the shared secret.
// Every message gets its own key, and the chain moves forward after each one.
// Alice creates a message and prepares to send it to Bob
let message = b"Hello, Bob! This is a secret message.";
let encrypted_message = alice_session.craft_message(message).unwrap();
// Bob decrypts and verifies Alice's message
let raw_message = bob_session.validate_message(&encrypted_message).unwrap();
// Both message and raw_message are equal, let's print them out to illustrate
let message_str = String::from_utf8_lossy(message);
let raw_message_str = String::from_utf8_lossy(&raw_message);
println!("[1] Alice's message: {}", message_str);
println!("[2] Bob's decrypted message: {}", raw_message_str);
// Bob crafts a reply message to Alice
let reply = b"Hello, Alice! I received your message safely.";
let encrypted_reply = bob_session.craft_message(reply).unwrap();
// Alice decrypts and verifies Bob's reply
let raw_reply = alice_session.validate_message(&encrypted_reply).unwrap();
// Both reply and raw_reply are equal, let's print them again
let reply_str = String::from_utf8_lossy(reply);
let raw_reply_str = String::from_utf8_lossy(&raw_reply);
println!("[3] Bob's reply: {}", reply_str);
println!("[4] Alice's decrypted reply: {}", raw_reply_str);
}Run this example with:
cargo run --example full_exchange- Prekeys. The responder creates ML-KEM-1024 key pairs ahead of time ("prekeys") and signs each public key with their FN-DSA identity key (
SignedPrekey). They keep the secret parts and share the signed public parts, directly or through a server. - Handshake. The initiator checks the prekey's signature against the responder's identity key, encapsulates a shared secret to the prekey and sends the ciphertext. Both sides share a 24-byte base nonce (a 16-byte session id plus an 8-byte starting counter).
- Transcript. Both sides hash the protocol label, the base nonce, both FN-DSA public keys, the prekey and the KEM ciphertext into a 32-byte transcript.
- Chain keys. HKDF-SHA256 (salt = transcript, input = shared secret) derives two chain keys: one for initiator โ responder and one for responder โ initiator. Both parties can send at the same time without ever reusing a key and nonce pair.
- Messages. For every message, the sender's chain key is split into a one-time message key and the next chain key, and the old chain key is wiped. The message is signed over
transcript || direction || counter || messageand encrypted with XChaCha20Poly1305 under the message key. The nonce issession id || counter. Binding the signature this way means a message can't be forwarded to another session, reflected back to its sender, or replayed.
Forward secrecy means a key stolen later can't decrypt traffic recorded earlier. pq-msg provides it at two levels:
- Within a session. Each message key is used once and the chain key only moves forward. Someone who steals a session (from memory, or a stored
to_bytes()copy) can't decrypt the messages that came before it. - Across sessions, through one-time prekeys. Once the responder deletes the one-time prekey a session used, nothing they still hold can decrypt that session's handshake.
This only works if the prekey rules are followed:
| Who | Must do |
|---|---|
| Responder's device | Create a batch of one-time prekeys plus one last-resort prekey, and sign them all with SignedPrekey::new. After new_responder with a one-time prekey, delete that prekey everywhere it is stored. Replace the last-resort prekey regularly (for example weekly), and publish more one-time prekeys before they run out. |
| Server | Hand out each one-time prekey only once, then remove it. Fall back to the last-resort prekey when none are left. Rate-limit fetches so an attacker can't drain them. |
| Initiator | Get the responder's FN-DSA identity key from a source you trust. new_initiator checks the prekey against it, so a server can't swap in its own prekey. Then send the responder the ciphertext, prekey.id(), the base nonce and your identity key. |
Sessions set up with the last-resort prekey only gain forward secrecy once that prekey is replaced and deleted.
Not provided: post-compromise security. If an attacker steals a live session, they can read its future messages until the session ends; the session doesn't "heal". Start new sessions regularly to limit this. Healing ratchets (such as Signal's Triple Ratchet) are much more complex and are not planned.
pq-msg itself is a library and doesn't include networking. The opt-in server feature adds PrekeyServer, an in-memory reference implementation of the server rules above. It is useful for prototypes and tests:
pq-msg = { version = "0.2", features = ["server"] }cargo run --example prekey_server --features serverFor production, follow the same rules with a real database and your own account authentication. See the server module documentation for what a production server must add.
- Messages must be validated in the order they were crafted. A replayed, reordered or tampered message is rejected, and the session stays usable.
- A session ends after 2โถโด messages per direction (
NonceExhausted). The counter never wraps around. - No post-compromise security (see above).
0.2 is a breaking release. The pqcrypto-* crates it relied on are unmaintained (RUSTSEC-2026-0164) because PQClean has been archived.
- Signing keys, signatures and serialized sessions from 0.1 can't be loaded. ML-KEM public keys and ciphertexts are unchanged, but secret keys are now stored as the 64-byte FIPS 203 seed.
- Signatures are detached:
SignerPair::sign(&mut self, msg)returns the signature, andViewOperations::verify(msg, sig)returns abool. - Sessions start from a signed prekey:
MessageSession::new_initiatortakes a&SignedPrekey(and checks its signature) instead of a raw KEM public key and your own KEM pair, andnew_responderborrows the matching prekey (&KEMPair). get_counteris replaced bysend_counterandrecv_counter.MessageSession::to_bytesreturns its bytes directly.KEMPair::encapsulateis an associated function that takes the receiver's public key bytes,decapsulatereturns the secret directly, andEncryptor::newtakes a 32-byte key.- Removed:
ss2b,b2ss,parse_ss(shared secrets are plain byte arrays now),verify_comp,verify_messageandverify_message_bytes. - Secret key material is wiped from memory on drop, and functions that return it wrap it in
Zeroizing.
For full documentation and examples, please visit docs.rs/pq-msg.
Contributions are welcome! Please feel free to submit a Pull Request.
This project is licensed under the MIT/Apache-2.0 dual license.