op4 — Secure Terminal Messenger
Op4 is a terminal-based encrypted messaging application written in Rust. It provides end-to-end encrypted private messaging with post-quantum cryptography, routed entirely through the Tor network so that neither the content of your messages nor your IP address is exposed to anyone — not even the person you are talking to.
- What op4 Does
- Installation
- Connecting to Another User
- Security Model
- Architecture Overview
- Project Layout
- Current Status
op4 lets two people exchange private messages without either party revealing their IP address or real identity. Every message is:
- End-to-end encrypted using a Double Ratchet protocol — the same fundamental design used by Signal.
- Post-quantum hardened — the key exchange layer combines classical X25519 with ML-KEM-768 (NIST-standardised lattice-based KEM), so a future quantum computer cannot retroactively decrypt recorded traffic.
- Anonymised — all traffic travels over Tor hidden services (.onion addresses). Your IP address is never exposed to your contact or to any network observer.
- Stored locally — there is no server. Your messages and contacts live in an encrypted vault file on your own machine, and nowhere else.
op4 runs entirely in the terminal. It has no GUI, no browser component, and no cloud account. The only external process it contacts is the Tor daemon running on your own machine.
All download options — AppImage, source tarball, clone & build, and automated installer — are documented in the Downloads & Install guide. The latest release is available on the Releases page.
On Debian and Ubuntu, install/setup.sh handles everything in one command:
Rust toolchain, build dependencies, Tor, control port configuration, binary
compilation, system user, data directory, and AppArmor profile.
git clone https://github.com/Opfour/op4.git
cd op4
sudo bash install/setup.shAfter the script finishes, you must log out and log back in before running op4. The installer adds your user to the
debian-torgroup so it can read the Tor cookie file. Linux does not apply group changes to already-open sessions — a fresh login is required.Skipping this step will cause op4 to fail at startup with:
Permission denied reading /run/tor/control.authcookie
Then verify the source hash printed by the script matches the published release hash for your version before trusting the binary.
When op4 starts it prints a source hash covering all Rust source files,
Cargo.toml, Cargo.lock, and build.rs. Compare it against the value
below for the version you installed.
| Version | Source hash |
|---|---|
0.3.0 |
80820cb41a63575d2c139dadd425d13d1e87e62a9d60200ae7b894ae2e9ad8ed |
0.3.1 |
48115efb12747fa78b627ddbf7a56c46169f59e777d7d7508941bf89e4fe7521 |
0.2.0-dev |
35740577f6c4a4f19c5a08fe85b1f78a10347f2ba9dd7642d126552266bfa5a5 |
0.1.0 |
e1a94761c7d3fa589ba892b47d5295aa417f95aee126809d51a7e7fb7e78982c |
You can also check it without launching the full app:
op4 --print-hashIf the hash does not match, do not use the binary — it was either built from a different commit or has been tampered with.
Install dependencies first, then run the script:
Rust toolchain (pinned to 1.89.0 via rust-toolchain.toml):
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"Tor and build dependencies:
# Fedora
sudo dnf install tor gcc pkg-config openssl-devel
# Arch
sudo pacman -S tor base-devel pkg-config opensslConfigure Tor control port — add to /etc/tor/torrc:
ControlPort 9051
CookieAuthentication 1
sudo systemctl restart torAdd your user to the Tor group:
sudo usermod -aG tor $USER # Fedora / ArchYou must log out and log back in after this step. Group membership changes are not applied to active sessions. Until you do, op4 will fail with
Permission denied reading /run/tor/control.authcookie.To apply the change without a full logout, run:
newgrp tor
Build and install:
git clone https://github.com/Opfour/op4.git
cd op4
cargo build --release
sudo bash install/setup.shop4
# or, without system install:
./target/release/op4On first launch op4 will guide you through setting a normal passphrase
and a duress passphrase, then generate your identity keys. Your vault is
stored at ~/.local/share/op4/vault.op4.
| Distro | Status |
|---|---|
| Ubuntu 22.04 / 24.04 | Supported |
| Debian 12 | Supported |
| Fedora 39+ | Supported |
| Arch Linux (current) | Supported |
| Tails OS | Supported (setup guide) |
| macOS / Windows / WSL1 | Not supported |
Minimum kernel: 4.15 (5.4+ recommended). Architecture: x86-64 (aarch64 should work but is untested).
Two people each need op4 installed, Tor running, and their vault unlocked. The exchange is asymmetric: one person sends their contact code first, the other adds it, then sends their first message, which arrives as a pending request that the first person accepts.
On first run op4 prompts for a normal passphrase and a duress
passphrase, then generates your identity keys. This only happens once.
Your vault is stored at ~/.local/share/op4/vault.op4.
$ op4
Your contact code contains your full public key bundle and your .onion
address. The other person needs this to reach you.
- Navigate to the Contacts tab (press
2or→) - Press
eto export your contact code - Your code appears on screen — it is a long Base58 string starting
with
op4: - Copy it and send it to the other person out of band (Signal, email, in person, etc.)
Your contact code is not secret. It is safe to share publicly. It contains only your public keys and onion address — no private material.
Once you have their contact code:
- In the Contacts tab, press
ato add a contact - Paste their
op4:contact code and press Enter - Give them a name and press Enter
- Select the contact from your contacts list (use
↑/↓, thenEnter) - Switch to the Messages tab (press
3or→) - Type your message and press Enter
This first message initiates the encrypted handshake and is delivered
to their .onion address over Tor. They will see it as a pending
contact request.
When someone sends you a first message, a badge appears on the Contacts tab showing how many requests are waiting.
- Go to the Contacts tab (press
2) - Press
pto review pending requests - You will see the sender's fingerprint and their first message
- Type a name for this contact and press Enter to accept
(press
Escto reject and discard)
Once accepted, the Double Ratchet is initialised and the conversation is immediately available in the Messages tab.
Before trusting a contact, confirm their fingerprint matches what they show on their own screen. This prevents a man-in-the-middle attack during the initial contact exchange.
- In the Contacts tab, select the contact
- The fingerprint panel on the right shows a 16-group hex string,
e.g.
A3F2:91BC:… - Ask the other person to read their fingerprint for your entry — it must match exactly
- Verification can be done over a voice call, in person, or any other channel where you can confirm their identity
| Key | Action |
|---|---|
1 / ← → |
Switch tabs (Contacts / Messages / Settings) |
↑ ↓ |
Move selection |
Enter |
Open conversation / confirm |
Esc |
Cancel / go back |
e |
Export your contact code (Contacts tab) |
a |
Add a contact (Contacts tab) |
p |
Review pending requests (Contacts tab) |
d |
Delete selected contact (Contacts tab) |
r |
Rotate your keys (Settings tab) |
q |
Quit |
If you believe your identity keys may be compromised:
- Go to the Settings tab (press
4or navigate right) - Press
rto rotate keys - op4 generates a new keypair, signs a revocation certificate with your old key, and broadcasts it to all your contacts over Tor
- Your contact code changes — share the new one with your contacts
| Purpose | Algorithm |
|---|---|
| Vault key derivation | Argon2id (m=64 MiB, t=3, p=1) |
| Vault encryption | ChaCha20-Poly1305 (256-bit key, 96-bit nonce) |
| Message encryption | ChaCha20-Poly1305 (per-message key from ratchet) |
| Key derivation (ratchet) | HKDF-SHA256 |
| Deniable authentication | HMAC-SHA256 |
| Classical key exchange | X25519 |
| Post-quantum key exchange | ML-KEM-768 (FIPS 203) |
| Classical signatures | Ed25519 |
| Post-quantum signatures | ML-DSA-65 (FIPS 204) |
| Transport anonymity | Tor v3 hidden services (.onion) |
op4 uses a Double Ratchet protocol (similar to Signal Protocol) for forward secrecy. This means:
- Each message is encrypted with a unique key derived from a ratchet chain.
- Compromising one message key does not expose any other message.
- If your device is seized, past messages that have already been deleted cannot be decrypted even if the attacker learns your vault passphrase.
The KEM step combines X25519 and ML-KEM-768 as follows:
shared_secret = HKDF(X25519_ss || MLKEM_ss)
An attacker must break both algorithms to compromise the key exchange. This protects against a quantum adversary (ML-KEM-768) while remaining secure against classical attacks if ML-KEM-768 has an unknown flaw (X25519 fallback).
Messages are authenticated with HMAC-SHA256 using a key derived from the shared ratchet state. Because both parties hold the same HMAC key, either party could have produced any given MAC. This is the same deniability property used by OTR and Signal: messages cannot be cryptographically attributed to a specific sender in a court proceeding.
The vault file at ~/.local/share/op4/vault.op4 stores all contacts,
conversations, and identity keys. It is protected with two independent
Argon2id-derived keys:
- Normal passphrase — unlocks your real contacts and messages.
- Duress passphrase — unlocks a visually identical but empty decoy inbox. Use this if you are coerced into unlocking the app. From the outside the two passphrases are indistinguishable.
op4 creates a Tor v3 hidden service for your inbox. Your .onion address
is derived deterministically from your identity key (via HKDF), so it is
stable across restarts without needing to store a separate key. Outbound
messages are sent through the Tor SOCKS5 proxy. Your real IP address never
appears in any network packet related to op4.
Cover traffic (Poisson-distributed dummy messages sent to self, mean interval 30 seconds) prevents a network observer from learning whether you are actively messaging someone by watching traffic volume.
- mlockall — prevents vault keys and plaintext from being written to swap.
- RLIMIT_CORE = 0 — disables core dumps that could expose memory.
- PR_SET_DUMPABLE = 0 — prevents other processes from attaching a debugger.
- seccomp-bpf — installs a syscall allowlist. Any syscall not in the list causes the process to be killed immediately.
- AppArmor profile (
apparmor/op4.profile) — restricts filesystem access to only the vault directory, terminal devices, and Tor.
┌─────────────────────────────────────────────────────────────┐
│ op4 process │
│ │
│ ┌──────────┐ ┌──────────────────┐ ┌────────────────┐ │
│ │ TUI │ │ Double Ratchet │ │ Tor Transport │ │
│ │ (ratatui)│──▶│ + Hybrid PQ │──▶│ nym_client.rs │ │
│ │ │ │ Crypto │ │ │ │
│ └──────────┘ └──────────────────┘ └───────┬────────┘ │
│ │ │
│ ┌──────────────────────────────┐ │ │
│ │ Encrypted Vault │ SOCKS5 / control port │
│ │ ~/.local/share/op4/vault.op4│ │ │
│ └──────────────────────────────┘ │ │
└────────────────────────────────────────────────┼───────────┘
│
┌────────────▼────────────┐
│ Tor daemon │
│ 127.0.0.1:9050 (SOCKS) │
│ 127.0.0.1:9051 (ctrl) │
└────────────┬────────────┘
│
Tor network
│
┌────────────▼────────────┐
│ Peer's .onion address │
│ (their hidden service) │
└─────────────────────────┘
op4/
├── src/
│ ├── main.rs Entry point, startup sequence
│ ├── error.rs Unified error types
│ ├── crypto/
│ │ ├── keys.rs Hybrid KEM + signature keypairs
│ │ ├── primitives.rs AEAD, HKDF, HMAC, Argon2id
│ │ ├── ratchet.rs Double Ratchet implementation
│ │ ├── hmac_auth.rs Deniable authentication tags
│ │ └── handshake.rs Initial key agreement (X3DH-style)
│ ├── network/
│ │ ├── nym_client.rs Tor hidden-service transport
│ │ └── message.rs Wire message format + padding
│ ├── storage/
│ │ └── vault.rs Encrypted vault (Argon2id + AEAD)
│ ├── identity/
│ │ ├── profile.rs Contact codes, stored contacts
│ │ └── revocation.rs Key revocation records
│ ├── hardening/
│ │ ├── memory.rs mlockall, RLIMIT_CORE, dumpable
│ │ └── seccomp.rs seccomp-bpf syscall filter
│ └── ui/
│ ├── app.rs TUI event loop and state machine
│ ├── contacts.rs Contacts tab rendering
│ ├── conversation.rs Messages tab rendering
│ ├── settings.rs Settings tab rendering
│ ├── duress.rs Duress inbox rendering
│ ├── input.rs Input sanitization (CSI/OSC strip)
│ └── passphrase.rs Secure passphrase prompts
├── apparmor/
│ └── op4.profile AppArmor MAC profile
├── install/
│ └── setup.sh System installation script
├── build.rs Embeds source hash at compile time
├── deny.toml cargo-deny licence + advisory rules
├── rust-toolchain.toml Pins Rust 1.89.0
└── docs/ This documentation
Version: 0.2.0-dev (pre-release)
op4 is under active development. The following layers are complete and tested:
- Vault (create, unlock, save, duress mode)
- All cryptographic primitives (12/12 unit tests passing)
- Tor hidden-service transport (connect, send, receive, cover traffic)
- Terminal UI (navigation, contacts management, conversation view)
- OS hardening (memory, seccomp, AppArmor)
All layers are now wired end-to-end:
- Identity keypairs (X25519+ML-KEM-768, Ed25519+ML-DSA-65) are generated on first run and stored encrypted in the vault.
- Contact code export produces a real Base58-encoded
PublicKeyBundle(your full public key set + onion address). - Sending a message performs a full X3DH-style handshake on the first message to a contact, then Double Ratchet encryption on subsequent messages, transmitted over Tor.
- Inbound messages are polled from the Tor transport in the TUI event loop and displayed in the conversation view (sealed-sender pattern: the routing layer never reveals who sent the message).
All known limitations have been resolved. The application is now at feature-complete status for 0.2.0-dev:
-
HMAC deniable authentication is fully wired. Every outbound data message carries an HMAC-SHA256 tag computed from the per-message ratchet key over
(conversation_id || message_counter || ciphertext). Inbound messages are verified before being accepted; zero-filled tags from older peers are tolerated for backward compatibility. -
Message history persists across restarts. The full conversation log is encrypted with a per-conversation HKDF-derived key and stored in the vault's
message_log_ctfield. Messages are loaded from the vault when a conversation is opened and written back after each send or receive. -
Inbound contact requests from unknown parties are queued rather than dropped. The Contacts tab shows a badge when requests are waiting. Press
[p]to review: you see the sender's fingerprint and their first message, type a name, and press Enter to accept (or Esc to reject). On acceptance the contact is added, the Double Ratchet is initialised, and the initial message is saved to the vault. -
Duress vault preserved across saves. The vault file format (v2) stores exact ciphertext lengths in the header so AEAD decryption operates on the real bytes rather than zero-padded sections. The encrypted duress section is stored verbatim on every
save()call, keeping the duress passphrase valid indefinitely. -
Dedicated Double Ratchet bootstrap key. A separate X25519 keypair (
identity_ratchet_secret) is generated on first run and included in the contact code asratchet_pub. Alice's ratchet is initialised with Bob'sratchet_pubrather than his KEM identity key, separating key roles. -
Settings tab fully functional. Tor SOCKS5 address and auto-delete threshold can be edited inline. Key rotation (generates new keypair, broadcasts a signed revocation certificate to all contacts, refreshes the export code) and key retirement revocation are both wired and operational.
-
Key revocation wired end-to-end.
RevocationCertificatestructs are signed with the hybrid Ed25519+ML-DSA-65 keypair and sent to all contacts asWireMessageType::Revocationmessages over Tor.
