pq-msg 0.2.0

Pure Rust abstractions for higher-level implementations of post-quantum cryptography in secure messaging protocols.
Documentation

pq-msg

Crates.io Documentation License

πŸ”’ 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-kem crate 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-dsa crate 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-msg will release another breaking version, and signing keys and stored sessions will need to be regenerated.

βš™οΈ Usage

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

πŸ” Protocol

  1. 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.
  2. 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).
  3. 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.
  4. 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.
  5. 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 || message and encrypted with XChaCha20Poly1305 under the message key. The nonce is session 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:

pq-msg = { version = "0.2", features = ["server"] }

cargo run --example prekey_server --features 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, and ViewOperations::verify(msg, sig) returns a bool.
  • Sessions start from a signed prekey: MessageSession::new_initiator takes a &SignedPrekey (and checks its signature) instead of a raw KEM public key and your own KEM pair, and new_responder borrows the matching prekey (&KEMPair).
  • get_counter is replaced by send_counter and recv_counter. MessageSession::to_bytes returns its bytes directly.
  • KEMPair::encapsulate is an associated function that takes the receiver's public key bytes, decapsulate returns the secret directly, and Encryptor::new takes a 32-byte key.
  • Removed: ss2b, b2ss, parse_ss (shared secrets are plain byte arrays now), verify_comp, verify_message and verify_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.