Post-quantum secure messaging over IRC. Research prototype.
v10.9.1 Β· Rust crypto core Β· hybrid PQC SMP (ML-KEM-1024 + ML-DSA-87) Β· I2P SAM Β· TUI
Full OTRv4 DAKE + SMP verification with hybrid PQC (ML-KEM-1024 + ML-DSA-87 + ZKP). Blue π΅ = identity confirmed. Tested live on both Libera.chat TLS and irc.postman.i2p I2P SAM.
Ctrl+P or Ctrl+S to pause and scroll back. Type /tui to toggle pinned chrome.
OTRv4+ is an IRC client that implements OTRv4 with post-quantum cryptography at every layer including hybrid PQC identity verification (SMP). It runs on Termux (Android) over I2P, Tor, or TLS clearnet, with a Rust crypto core wrapped by a thin Python orchestration layer.
Single-author research prototype. Not a finished product. The author is not a cryptographer. The Rust crypto crates it depends on (ed448-goldilocks-plus, x448, pqcrypto-mlkem, pqcrypto-mldsa) are not audited. Use it to study or extend it, not because you need a hardened tool today.
For someone who wants to try it in about ten minutes on Termux (Android, aarch64).
pkg install python rust openssl clang gitFor clearnet/TLS (fastest, no extra setup):
python otrv4+.py -s irc.libera.chatFor I2P (strongest anonymity): you need I2P running with the SAM bridge enabled on port 7656. The I2P Android app from F-Droid or Google Play handles this; enable "Use SAM bridge" in its settings.
For Tor: Orbot must be running with SOCKS5 on port 9050.
git clone https://github.com/muc111/OTRv4Plus.git
cd OTRv4Plus
# Build the Rust crypto core (about 3 minutes on a modern phone)
cd Rust
cargo build --release --no-default-features --features pq-rust
cp target/release/libotrv4_core.so ../otrv4_core.so
cd ..As of v10.7.5 the project is Rust-core-only: there are no C extensions to compile and no Python cryptography dependency. The Rust core is the single cryptographic surface.
cd Rust
cargo test --release --no-default-features --features pq-rust
cd ..Expected: test result: ok. 30 passed; 0 failed (17 existing + 15 new hybrid PQC SMP tests). The tests that matter most are test_vectors::tests::ed448_rfc8032_vectors_byte_exact (Rust Ed448 against RFC 8032) and key_handles::tests::x448_rfc7748_known_answer (Rust X448 against RFC 7748 Β§5.2). If either fails, a Rust crypto implementation has drifted from its specification and the build is not safe to use.
PYTHONMALLOC=malloc python otrv4+.py --debugYou should see the banner, the I2P SAM bridge handshake, the IRC connection to irc.postman.i2p, an auto-join of #otr, and a prompt. Other users in #otr running the same client are available for an OTR session.
If another user is in #otr (their nick is SomeNick), type:
/otr SomeNick
This starts the OTRv4 DAKE handshake. Fingerprints display once the DAKE handshake completes. Type y to trust. Either side then types a shared SMP secret (agreed out of band) and runs /smp start.
Typical completion times (measured, hybrid PQC SMP v10.9.1):
- TLS clearnet (Libera.chat): DAKE + SMP verified in under 6 minutes
- I2P SAM (irc.postman.i2p): DAKE + SMP verified in ~15β16 minutes (fragment rate limiting required due to server flood policy)
- Tor: 8β12 minutes (estimated)
You see β
SMP VERIFIED in blue when done.
From that point, messages typed in the peer tab are end-to-end encrypted with post-quantum hybrid security.
OTRv4+ includes a built-in terminal UI that pins a tab bar and input line at the bottom of the screen, keeping your chat history visible above it β useful on mobile where screen space is limited.
22:11:29 π΅CobaltBear: works
22:11:30 π΅WildTallow: nice
22:13:57 π΅WildTallow: ok
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
WildTallow | [π΅CobaltBear]
[systemπ΄(1)] | [debugπ΄(6929)] | [#otrπ΄(1)] | [CobaltBearπ΅]
Toggle it on or off:
/tui
Or explicitly:
/tui on
/tui off
TUI is off by default β the client works as a standard scrollback IRC client without it. Enable it when you want the pinned chrome, especially on Termux where the terminal doesn't scroll cleanly.
Tab switching (works with or without TUI):
/switch CobaltBear # jump to a peer tab by name
/tab-next # cycle right
/tab-prev # cycle left
Tabs show security level icons β π΄ plaintext, π‘ encrypted, π’ trusted fingerprint, π΅ SMP verified. Unread message counts appear in brackets: [#otrπ΄(3)].
During a clean DAKE in --debug mode, you should see lines like:
[ClientProfile] Fresh Rust-owned identity keys β expires <date>
[OTR:peer] SESSION: None β PLAINTEXT | session created
[OTR:peer] ROLE: None β INITIATOR
...
[OTR:DAKE] STATE: IDLE β RECEIVED_DAKE1 | received DAKE1 (Identity)
[OTR:DAKE] STATE: RECEIVED_DAKE1 β SENT_DAKE2 | generated DAKE2 (Auth-R)
[OTR:DAKE] STATE: SENT_DAKE2 β ESTABLISHED | DAKE3 verified β hybrid (ring-sig β + ML-DSA-87 β)
[OTR:peer] RATCHET: None β ACTIVE | ratchet: Rust (Phase-4 opaque handle; keys never in Python)
[OTR:peer] SMP: VERIFIED β STATE_UPDATED | role=responder
π΅β
SMP VERIFIED β identity confirmed! (ML-KEM-1024 + ML-DSA-87 + ZKP)
After that, the peer tab is green (encrypted + verified) and your typed messages are end-to-end encrypted.
βββββββββββββββββββββββββββββββββββββββββββββββ
β IRC transport (I2P / Tor / TLS 1.3) β
βββββββββββββββββββββββββββββββββββββββββββββββ€
β Python orchestration layer β
β (thin wrapper, no secrets on Python heap, β
β no Python cryptography library) β
βββββββββββββββββββββββββββββββββββββββββββββββ€
β Rust core (otrv4_core) β
β Ed448KeyHandle / X448KeyHandle β
β verify_ed448_sig β
β Double Ratchet (X448 DH in Rust) β
β DAKE state machine β
β SMP state machine β
β DakeOutput opaque handle β
β SecretBytes / SecretVec β
β ZeroizeOnDrop everywhere β
βββββββββββββββββββββββββββββββββββββββββββββββ€
β Pure-Rust crypto crates β
β ed448-goldilocks-plus, x448, sha3, β
β aes-gcm, pqcrypto-mlkem (FIPS 203 β
β ML-KEM-1024), pqcrypto-mldsa (FIPS 204 β
β ML-DSA-87) β
βββββββββββββββββββββββββββββββββββββββββββββββ
As of v10.7, the Python cryptography library has been fully removed from the codebase. Every Ed448, X448, AES-256-GCM, and ML-DSA-87 operation runs inside the Rust otrv4_core core. There is no OpenSSL-backed Python crypto in any code path.
As of v10.7.5 (Phase 5.3k) all C extensions have been retired. The previous otr4_crypto_ext, otr4_ed448_ct, and otr4_mldsa_ext shared libraries are deleted from the repo and the setup_otr4.py build target removed. Every cryptographic operation now runs inside the Rust otrv4_core module: ML-KEM-1024 (FIPS 203), ML-DSA-87 (FIPS 204), Ed448 and X448 (ed448-goldilocks-plus), AES-256-GCM (aes-gcm), and the Argon2id-class KDF that protects the SMP secret vault. Memory wiping uses Rust zeroize::Zeroize on Rust-owned buffers and ctypes.memset for the remaining bytearrays held on the Python side.
Three-message handshake per OTRv4 Β§4.2 and Β§4.3. X448 ephemeral DH plus ML-KEM-1024 encapsulation. Both peers contribute entropy.
The entire DAKE, including all session-key derivation, runs in Rust. X448 DH exchanges (dh1, dh2, dh3), ML-KEM encap and decap, MAC over the DAKE2 wire body, Ed448 ring signature verification for DAKE3, and the KDF chain that produces root_key, chain_key_send, chain_key_recv, brace_key, and mac_key all run inside otrv4_core. The pure-Python OTRv4DAKE fallback that earlier versions carried was deleted in v10.7; the Rust DAKE is the only DAKE implementation.
Session keys cross from DAKE into the ratchet via a Rust-only move. The DakeOutput PyO3 handle holds the keys in a private RefCell<Option<DakeSessionKeys>> with no Python-visible accessor. consume_into_ratchet() moves them directly into the ratchet's owned SecretBytes fields. Session keys are never marshalled into PyBytes at any point.
Ed448 and X448 identity keys are generated inside Rust at session start. The Python ClientProfile.identity_key and .prekey are opaque Ed448KeyHandle and X448KeyHandle objects. Each handle owns SecretBytes<N> and exposes only public_bytes() and the operations the protocol needs (sign(), ring_sign(), dh()). Private bytes are not retrievable from Python by any public method.
When the handle is garbage-collected, Rust's ZeroizeOnDrop runs and wipes the SecretBytes before the heap slot is reclaimed.
Chain keys advance per message via SHAKE-256 KDF. DH ratchet at rekey boundaries (100 messages or 24 hours). Fresh ML-KEM-1024 keypair generated and exchanged at every DH ratchet step. Brace key rotated with each KEM shared secret. Skipped message keys cached for out-of-order delivery (max 1000 skip).
As of v10.7, the ratchet's X448 Diffie-Hellman runs entirely in the Rust core via X448KeyHandle. The x448 crate clamps the scalar per RFC 7748 and rejects low-order points, matching OpenSSL byte-for-byte; an RFC 7748 Β§5.2 known-answer test gates the build.
Ed448 ring signatures provide deniable authentication in DAKE3. The ring signature is implemented in pure Rust using ed448-goldilocks-plus and sha3 for SHAKE-256. ML-DSA-87 is appended as hybrid post-quantum auth. ClientProfile signature verification on incoming peers runs through the Rust verify_ed448_sig function. SMP provides out-of-band identity verification via a hybrid post-quantum four-step protocol: the classical OTRv4 Schnorr ZKP over a 3072-bit safe prime runs alongside ML-KEM-1024 key encapsulation and ML-DSA-87 per-step signatures. The pq_binding_key derived from the KEM shared secret binds every ML-DSA-87 signature to the session. All SMP state runs in Rust with ZeroizeOnDrop on every exponent and key.
As of v10.9.0, identity verification uses a hybrid post-quantum SMP protocol. The classical OTRv4 four-step Schnorr ZKP over a 3072-bit safe prime group runs alongside an ML-KEM-1024 and ML-DSA-87 binding layer.
How it works:
- SMP1 β initiator generates ML-KEM-1024 and ML-DSA-87 keypairs, appends the KEM encapsulation key (1568 bytes) and ML-DSA-87 public key (2592 bytes) to the classical payload
- SMP2 β responder encapsulates to derive
kem_ss, computespq_binding_key = KDF(kem_ss || transcript_tag), signs the entire SMP2 wire body with ML-DSA-87 under that binding key - SMP3/4 β each side verifies the previous ML-DSA-87 signature before processing classical fields, then signs its own output
Security: breaking the equality proof requires breaking all three of the 3072-bit discrete log, ML-KEM-1024, and ML-DSA-87 simultaneously. The wire format is versioned (0x02 = hybrid PQ) with no silent downgrade possible.
Wire overhead: SMP1 grows from ~1.4 KB to ~8.1 KB (18 fragments), SMP2 from ~3.1 KB to ~16.4 KB (49 fragments) due to ML-KEM-1024 and ML-DSA-87 key material.
Fragment rate limiting on I2P: irc.postman.i2p enforces strict flood limits. The client uses a batch send strategy (2 fragments, 6-second pause) keeping traffic at ~0.33 lines/second average. At 49 fragments SMP2 takes ~2.5 minutes to send. Full DAKE+SMP over I2P completes in ~15 minutes. SMP session timeout is 45 minutes to accommodate I2P latency.
Measured timings (v10.9.1, live tested):
| Transport | Server | DAKE complete | SMP verified | Total |
|---|---|---|---|---|
| TLS clearnet | Libera.chat | ~3 min | ~5 min | ~6 min |
| I2P SAM | irc.postman.i2p | ~6 min | ~15 min | ~15β16 min |
| Tor | β | ~5 min | ~10 min | ~12 min (est.) |
| Component | Where secrets live | Python sees |
|---|---|---|
| Ratchet chain / root keys | Rust SecretBytes<32> |
Nothing |
| Ratchet brace key | Rust SecretBytes<32> |
Nothing |
| DAKE DH secrets | Rust heap | Nothing |
| DAKE session keys | Rust DakeSessionKeys β DoubleRatchet::SecretBytes (Rust-to-Rust move) |
Nothing |
| Long-term Ed448 identity | Rust SecretBytes<57> inside Ed448KeyHandle |
Public bytes only |
| Long-term X448 prekey | Rust SecretBytes<56> inside X448KeyHandle |
Public bytes only |
| SMP secret | Rust SecretVec inside RustSMPVault |
Nothing after set_secret_from_vault |
| SMP exponents | Rust scalars with ZeroizeOnDrop |
Nothing |
| SMP ML-KEM-1024 secret key | Rust SecretVec with ZeroizeOnDrop |
Nothing |
| SMP ML-DSA-87 signing key | Rust SecretBytes<4896> with ZeroizeOnDrop |
Nothing |
| SMP pq_binding_key | Rust SecretBytes<32>, wiped after each step |
Nothing |
Every value with ZeroizeOnDrop is wiped when its owning Rust object is dropped. No private key material appears on the Python heap during normal session operation.
Earlier versions ran a boot-time cross-verification that signed a test message with Rust Ed448 and the Python cryptography library and compared the byte output. v10.6.17 replaced that with hardcoded RFC 8032 Β§7.4 Ed448 test vectors in Rust/src/test_vectors.rs. v10.6.21 added an RFC 7748 Β§5.2 X448 known-answer vector in Rust/src/key_handles.rs. The cargo test harness exercises both and asserts byte equality with the published values.
Run cargo test --release --no-default-features --features pq-rust before any release. If a vector test fails, the corresponding Rust crate has drifted from its RFC and the build should not ship.
-
Single author, no external review. Code style is consistent but design choices have not been peer-reviewed.
-
Built with AI assistance (Claude). The author drove design and testing; the AI helped with implementation. Each substantive change was live-tested between two I2P peers before being committed.
-
The Rust crypto crates are not audited.
ed448-goldilocks-plus0.16 is the only viable pure-Rust Ed448 implementation but has no formal review.x4480.6 is a pure-Rust X448 with no formal review.pqcrypto-mlkem 0.1.1(FIPS 203 ML-KEM-1024) andpqcrypto-mldsa 0.1.2(ML-DSA-87) are PQClean-derived reference implementations. -
Rust-core-only since v10.7.5. Every C extension (
otr4_crypto_ext,otr4_ed448_ct,otr4_mldsa_ext) has been retired and the Pythoncryptographylibrary was removed at v10.7. The entire cryptographic surface of the client now lives inside the Rustotrv4_corePyO3 module β there is no second crypto implementation to drift against. As of v10.7.6 (Phase 5.4) the SMP modular exponentiation is constant-time viacrypto-bigintDynResidue, closing a timing side-channel on the secret SMP exponents. As of v10.9.1 the SMP protocol is hybrid post-quantum: ML-KEM-1024 encapsulation and ML-DSA-87 per-step signatures wrap the classical ZKP, requiring all three primitives to be broken simultaneously. See the CHANGELOG v10.6.18 β v10.7.6 sequence for the migration history. -
Ephemeral identity by design. Identity keys regenerate at every launch. Fingerprints change on every restart. This is a deliberate threat-model choice for an I2P-based privacy IRC client, not a missing feature. Tor Browser, Cwtch (default), and Briar (before user opt-in) all keep identities short-lived for similar reasons. See ROADMAP Phase 5.3g.
-
Wire-incompatible with stock OTRv4. Implementations such as
pidgin-otr4and CoyIM cannot talk to OTRv4+. The ML-DSA-87 extension, the ML-KEM-1024 brace key, and the SHAKE-256 transcript hashing are OTRv4+ additions and there is no negotiation path. Both peers must run OTRv4+. -
Termux/aarch64 specific build flags. Both
pqcrypto-mlkemandpqcrypto-mldsaare pinned todefault-features = false, features = ["std"]because their NEON-optimised C paths triggerSIGILLon some aarch64 phones. The portable C reference is correct on any platform; the speed difference is invisible at session scale.
GPL-3.0 for the source. Commercial use requires a separate license (see COMMERCIAL-LICENSE.md).
- CHANGELOG.md per-version changes
- SECURITY.md threat model and known issues
- FEATURES.md full feature inventory
- ROADMAP.md what's planned next
- DEVELOPMENT.md build environment, test plan
- CONTRIBUTING.md PR guidelines
- WHY.md design rationale
- MIGRATION.md moving from earlier versions
