pq-msg
π Overview
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)]).
π οΈ Cryptographic Foundation
| 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 |
β οΈ Security Warnings
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.
βοΈ Usage
use ;
Run this example with:
π Protocol
- 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
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.
π§ Prekey Server (optional)
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:
= { = "0.2", = ["server"] }
For 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.
β οΈ Limitations
- 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).
β¬οΈ Upgrading from 0.1
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.
π Documentation
For full documentation and examples, please visit docs.rs/pq-msg.
π€ Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
π License
This project is licensed under the MIT/Apache-2.0 dual license.