Skip to content

muc111/OTRv4Plus

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

63 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

OTRv4+

OTRv4+

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


In action

OTRv4+ TUI β€” encrypted session with SMP verified

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.


What this is

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.

Quick start

For someone who wants to try it in about ten minutes on Termux (Android, aarch64).

1. Install dependencies

pkg install python rust openssl clang git

For clearnet/TLS (fastest, no extra setup):

python otrv4+.py -s irc.libera.chat

For 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.

2. Clone and build

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.

3. Verify the build (recommended)

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.

4. Run it

PYTHONMALLOC=malloc python otrv4+.py --debug

You 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.

5. Talk to a peer

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.

TUI mode

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)].

What success looks like

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.

Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  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.

Key exchange (DAKE)

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.

Long-term identity

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.

Double ratchet

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.

Authentication

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.

Hybrid PQC SMP (v10.9.1)

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, computes pq_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.)

Memory safety

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.

RFC build-time gates

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.

Honest caveats

  1. Single author, no external review. Code style is consistent but design choices have not been peer-reviewed.

  2. 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.

  3. The Rust crypto crates are not audited. ed448-goldilocks-plus 0.16 is the only viable pure-Rust Ed448 implementation but has no formal review. x448 0.6 is a pure-Rust X448 with no formal review. pqcrypto-mlkem 0.1.1 (FIPS 203 ML-KEM-1024) and pqcrypto-mldsa 0.1.2 (ML-DSA-87) are PQClean-derived reference implementations.

  4. Rust-core-only since v10.7.5. Every C extension (otr4_crypto_ext, otr4_ed448_ct, otr4_mldsa_ext) has been retired and the Python cryptography library was removed at v10.7. The entire cryptographic surface of the client now lives inside the Rust otrv4_core PyO3 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 via crypto-bigint DynResidue, 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.

  5. 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.

  6. Wire-incompatible with stock OTRv4. Implementations such as pidgin-otr4 and 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+.

  7. Termux/aarch64 specific build flags. Both pqcrypto-mlkem and pqcrypto-mldsa are pinned to default-features = false, features = ["std"] because their NEON-optimised C paths trigger SIGILL on some aarch64 phones. The portable C reference is correct on any platform; the speed difference is invisible at session scale.

License

GPL-3.0 for the source. Commercial use requires a separate license (see COMMERCIAL-LICENSE.md).

See also

About

Termux IRC client with post-quantum OTRv4 NIST Level 5 throughout. Rust core: ML-KEM-1024 brace key rotation, ML-DSA-87 hybrid auth, Ed448 ring signatures, double ratchet, hybrid PQC SMP (ML-KEM-1024 + ML-DSA-87 per-step signatures + ZKP). ZeroizeOnDrop on all secrets. I2P SAM / Tor / TLS 1.3. Zero traces on exit.

Topics

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors