PQChat is a quantum threat resistant peer-to-peer chat application.
This is a case study permetting not only to play with libp2p but also to test Open Quantum Safe library.
This is open source.
This should not be used in production.
Apache 2.0
- Build the Docker images
docker build --no-cache -t pqchat -f docker/Dockerfile-oqs .
- Launch relayer :
docker-compose up relayer
...
Attaching to pqchat-relayer
pqchat-relayer | 2025/12/02 10:22:19 failed to sufficiently increase receive buffer size (was: 208 kiB, wanted: 7168 kiB, got: 416 kiB). See https://github.com/quic-go/quic-go/wiki/UDP-Buffer-Sizes for details.
pqchat-relayer | Relay PeerID: 12D3KooWCFHCXBD3hs2uMwx6EdXKzqoMTRYhZ4nfW74gogSRLXJr
pqchat-relayer | /ip4/127.0.0.1/tcp/4001
pqchat-relayer | /ip4/127.0.0.1/udp/4001/quic-v1
pqchat-relayer | /ip4/172.20.0.10/tcp/4001
pqchat-relayer | /ip4/172.20.0.10/udp/4001/quic-v1
Recreate the multiaddr of the relayer; here /ip4/172.20.0.10/tcp/4001/p2p/12D3KooWCFHCXBD3hs2uMwx6EdXKzqoMTRYhZ4nfW74gogSRLXJr
- Launch a client
You can launch as many as you want
=> You have to check the network name
docker run -it --network pqchat_pqchat-network pqchat bash
root@0b99af69dc18:/app# /usr/local/bin/pqchat -relay /ip4/172.20.0.10/tcp/4001/p2p/12D3KooWCFHCXBD3hs2uMwx6EdXKzqoMTRYhZ4nfW74gogSRLXJr
- Fight Harvest Now Decrypt Later threat with a quantum-resistant transport with ML-KEM
- Ensure quantum-resistant identity with ML-DSA
- User may have an optional pseudo
- Messages may be broadcasted to all, or unicasted to one or more users
- Connect using P2P mechanisms
Each user has an identity keypair:
- Algorithm: e.g.
ML-DSA-65(NIST level 1) - Public key:
id_pub - Private key:
id_priv - Pseudo: user-chosen string
User can:
- generate a keypair before running the application; the application will load them
- let the application generate the key pair
We define a user ID as:
user_id = SHA256( id_pub || pseudo )
Every message is signed:
signature = ML-DSA.Sign(id_priv, message_bytes)
and verified by others with id_pub.
Each pair of users (A,B) has a shared symmetric key:
-
A and B establish a libp2p stream (Noise-encrypted transport)
-
They run ML-KEM-768 handshake inside the stream:
- A (server role) → generate ML-KEM keypair
(pkA, skA) - A → send
pkAto B - B →
Encap(pkA)→(ct, ss) - B → send
ctto A - A →
Decap(skA, ct)→ss
- A (server role) → generate ML-KEM keypair
-
Both have the same shared secret
ss -
Derive AES key:
aes_key = HKDF( ss, "PQCHAT-SESSION", 32 bytes )
-
Use AES-GCM (128 or 256 bits) for message confidentiality:
ciphertext = AES_GCM_Encrypt(aes_key, nonce, plaintext, aad)plaintext = AES_GCM_Decrypt(...)
This gives a PQC-resistant channel between each pair of users.
For broadcast, simplest PoC:
- each message is individually encrypted for each recipient with their per-peer AES key
- then sent directly over that connection
- that’s O(N) per broadcast, but fine for a demo.
Example: HELLO
{
"type": "HELLO",
"pseudo": "Alice",
"user_id": "hex(SHA256(id_pub||pseudo))",
"ml_dsa_pub": "base64(...)",
"libp2p_peer_id": "12D3KooW...",
"sig": "base64( ML-DSA.Sign(id_priv, canonical_json_without_sig) )"
}Peers store (user_id → {pseudo, ml_dsa_pub, peer_id}) once verified.
Before encryption/signature, “logical” message:
{
"type": "CHAT",
"from": "user_id",
"to": ["user_id_1", "user_id_2"], // empty or ["*"] = broadcast
"body": "Hello world",
"timestamp": 1732970000
}Then:
- Serialize to canonical JSON →
m - Compute signature:
sig = ML-DSA.Sign(id_priv, m)
- Build signed envelope:
{
"msg": { ...as above... },
"sig": "base64(sig)",
"pub": "base64(id_pub)" // or omit if cached from HELLO
}- Encrypt envelope with AES-GCM session key → ciphertext bytes
- Prepend framing (len, etc.) and send over libp2p stream.
Receiver:
- decrypt AES-GCM
- parse JSON
- verify
ML-DSA.Verify(pub, msg, sig) - display if ok.
One binary, two roles:
- Node: always a P2P peer (libp2p host)
- Pseudos / keypairs:
pqchat \
-pseudo "Alice" \
-ml-dsa-priv ./keys/alice-ml-dsa-priv.bin \
-ml-dsa-pub ./keys/alice-ml-dsa-pub.bin \
-listen "/ip4/0.0.0.0/tcp/0" \
-peer "/ip4/…/tcp/4001/p2p/12D3KooW…" # bootstrap/relay or other peersIf keys don’t exist, the program can:
- generate ML-DSA keypair
- save to files
- print a warning (“new identity created”).
Chat UX in terminal:
hello everyone→ default: broadcast@alice hi→ send only to useralice@alice @bob secret→ send to both
Map pseudos → user_ids → peers.
You need a machine running:
- macOS (Intel or ARM)
- Ubuntu/Linux
- Raspberry Pi OS (ARM64) → supported after installing liboqs manually
- Go 1.21+
PQChat depends on:
| Component | Purpose |
|---|---|
| OpenSSL 3.x | PQC provider support (liboqs or oqs-provider) |
| liboqs (C library) | PQC primitives (ML-KEM, ML-DSA, BIKE, Frodo…) |
| liboqs-go (Go wrappers) | Go bindings calling liboqs |
| pkg-config | Required for liboqs-go compilation |
| libp2p | P2P networking |
| cgo | To call liboqs from Go |
| Make | For building binaries |
If you run:
go run examples/kem/kem.go
and obtain shared secrets → everything is OK.
One can use
Please follow liboqs-go README
Execute the following to check the installation:
go run liboqs-go/examples/kem/kem.goExpected output:
liboqs version: 0.15.0-rc1
Enabled KEMs: [ML-KEM-512 ML-KEM-768 …]
Shared secrets coincide? true
If this works → PQChat will compile.
From the project root:
make all
This produces:
bin/relayer
bin/pqchat
On machine A (can be behind NAT):
./bin/relayerYou will see:
Relay PeerID: 12D3K...
/ip4/192.168.1.X/tcp/4001
/ip4/xxx.xxx.xxx.xxx/tcp/4001
Copy this address.
On machine B:
./bin/pqchat --relay /ip4/.../p2p/12D3K...You will see:
✔ Connected to relay
✔ PQC handshake (ML-KEM-768) complete
Your PQ identity (ML-DSA-65): <hex>
Then you can:
- send messages
- broadcast
- direct-message specific peers
- inspect PQ handshake logs
- PQ identity = ML-DSA public key
- PQ handshake = ML-KEM-768 (Kyber)
- Session key = AES-GCM using ML-KEM shared secret
- No classical crypto fallback
- No Noise → fully PQC end-to-end
- Each peer only processes tasks for its own identity (future work)
You can now:
- run a decentralized relay
- chat securely across NAT
- use ML-KEM for P2P key exchange
- sign PQ identities with ML-DSA
- experiment with PQC + P2P networking
A relay is optional if both peers are on the same machine, connecting through TCP stack.
- Launch first peer
In a first terminal :
./bin/pqchat
⚠️ No relay configured, running in direct TCP mode.
Local PeerID: 12D3KooWMBjeJ5aewPjZTseS6rugXB2PJRDCfGKJR4xUmFmiM6m9
Listening on:
/ip4/127.0.0.1/tcp/56142
/ip4/192.168.64.1/tcp/56142Recreate its multiaddr with the port and the PeerID.
/ip4/192.168.64.1/tcp/56142/p2p/12D3KooWMBjeJ5aewPjZTseS6rugXB2PJRDCfGKJR4xUmFmiM6m9
- Launch a second peer and connect to the first one
In another terminal (use the multiaddr of the first node):
./bin/pqchat -connect /ip4/192.168.64.1/tcp/56142/p2p/12D3KooWMBjeJ5aewPjZTseS6rugXB2PJRDCfGKJR4xUmFmiM6m9
⚠️ No relay configured, running in direct TCP mode.
Local PeerID: 12D3KooWLUSKEb5WTijoqTVYUJXjDsP8SSBk8qPGw7vpqLPaSkPA
Listening on:
/ip4/127.0.0.1/tcp/56268
/ip4/192.168.64.1/tcp/56268
Connecting to peer: 12D3KooWMBjeJ5aewPjZTseS6rugXB2PJRDCfGKJR4xUmFmiM6m9
Running PQC client handshake…
PQC session established (client side)- Launch the relay
In a first terminal :
./bin/relayer
relay listening on:
/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWrelay...→ Copy the multiaddr of the relay.
- Launch a first peer
In a second terminal:
./bin/pqchat -pseudo alice -relay /ip4/127.0.0.1/tcp/4001/p2p/12D3KooWrelay- Launch a second peer
In a third terminal:
./bin/pqchat -pseudo bob -relay /ip4/127.0.0.1/tcp/4001/p2p/12D3KooWrelay \
-connect /ip4/127.0.0.1/tcp/XXXXX/p2p/<PeerIDAlice>