Encrypt a message for someone, paste the result anywhere (chat, e-mail, a forum), and only they can read it — and they can see it really came from you. Optional sessions add forward secrecy with the Signal Double Ratchet. All of it is hybrid post-quantum: X25519 + ML-KEM-768 for key agreement, Ed25519 + ML-DSA-65 for signatures (FIPS 203 / 204), so an attacker has to break both the classical and the post-quantum algorithm.
- It does not send anything. You copy the
ezc1:…/ezs1:…text and deliver it yourself. - It does not hide that you are talking, message sizes, or who holds which key. Only the contents.
- It has nothing to do with Ethereum any more (1.x used Ethereum's ECIES; 3.0 reads nothing older than itself).
Prebuilt Windows / Linux / macOS builds are on the latest release (no Python needed). From source:
pip install -r requirements.txt
python main.py # GUI (run.cmd / run.sh)
python ezcoder.py --help # CLI (frozen build: ezcoder --cli --help)
On first start a key pair is created in keys.json next to the app (or in $EZCODER_HOME). The Keys tab shows
your public identity (ezid3:…, about 4.3 KB of text because it carries the post-quantum keys), its fingerprint and a QR code of the fingerprint — share the identity text, never the file.
- Message tab (stateless): paste the recipient's
ezid3:identity (or pick a contact), type, Encrypt. To read a message, paste it and Decrypt — ezcoder tells you who signed it and whether that person is in your contacts. - Sessions tab (forward secret): add contacts by name + identity. Encrypt in session produces a message that also carries the handshake the first time; the other side just decrypts it and can answer. Messages may arrive out of order or get lost (up to 1000 skipped keys are kept). Reset session starts over.
- Keys tab: copy identity, protect
keys.jsonwith a passphrase (scrypt + ChaCha20-Poly1305), export / import, generate a new identity.
Compare fingerprints with your contact over a channel you trust (phone, in person). That is the only protection against someone handing you a swapped identity.
ezcoder.py keygen [--passphrase] ezcoder.py id
ezcoder.py fingerprint ezid3:... ezcoder.py contact add NAME ezid3:... | list | remove WHO
ezcoder.py encrypt ezid3:... -m "text" | -f file ezcoder.py decrypt -m "ezc3:..." | -f file (stdin works too)
ezcoder.py session send WHO -m "text" ezcoder.py session list | reset WHO
--home DIR selects the key folder, EZCODER_PASSPHRASE supplies the passphrase non-interactively.
Everything uses primitives from the Python cryptography package (≥ 50, which ships
ML-KEM and ML-DSA via OpenSSL); nothing is hand-rolled and there are no other crypto dependencies. All constants below
are in ezcrypto.py (≈ 400 lines — please review it). Hybrid everywhere: never PQ-only, never classical-only.
Identity. X25519 + ML-KEM-768 (FIPS 203) for key agreement, Ed25519 + ML-DSA-65 (FIPS 204, NIST level 3 to match
ML-KEM-768) for signatures. ezid3: + base64url(dh_pub₃₂ ‖ sign_pub₃₂ ‖ kem_pub₁₁₈₄ ‖ pqsign_pub₁₉₅₂) = 3200 bytes,
4273 characters. Fingerprint = first 64 bits of SHA-256 over those 3200 bytes as xxxx xxxx xxxx xxxx.
Every signature is a pair Ed25519(data) ‖ ML-DSA-65(data, ctx="ezcoder/v3") (64 + 3309 bytes); both must verify.
Sealed message ezc3: + base64url(0x03 ‖ eph_pub₃₂ ‖ kem_ct₁₀₈₈ ‖ recipient_id₈ ‖ ciphertext)
| recipient_id | SHA-256(recipient dh_pub ‖ kem_pub)[:8] — a wrong recipient gets "not encrypted for your key", not a MAC failure |
| shared secret | X25519(eph, recipient.dh) ‖ ML-KEM-768.Decaps(recipient.kem, kem_ct) |
| key ‖ nonce | HKDF-SHA256(ikm = shared, salt = ∅, info = ezcoder/v3/sealed ‖ eph_pub ‖ kem_ct ‖ recipient.dh ‖ recipient.kem) → 32 + 12 B |
| ciphertext | ChaCha20-Poly1305(key, nonce, aad = the 1129-byte header, plaintext = inner) |
| inner | sender identity₃₂₀₀ ‖ sig₃₃₇₃ ‖ message |
| sig | over ezcoder/v3/sealed-sig ‖ eph_pub ‖ kem_ct ‖ recipient.dh ‖ recipient.kem ‖ SHA-256(message) |
Overhead: ~7.7 KB binary / ~10.3 KB of text per message (the sender's full identity travels inside so an unknown sender can still be verified and added as a contact). A fresh ephemeral key and KEM ciphertext per message mean the nonce is never reused under a key. The signature binds the message to this ephemeral key, this KEM ciphertext and this recipient, so a recipient cannot re-wrap it for someone else. The sender's identity is inside the ciphertext.
Session — Signal Double Ratchet with a
PQXDH-style hybrid handshake (no prekey server, both sides only have
identity bundles):
SK = HKDF-SHA256(DH(IK_A, IK_B) ‖ DH(EK_A, IK_B) ‖ ML-KEM-768.Encaps(IK_B.kem), info = ezcoder/v3/pqxdh),
Bob's initial ratchet key is IK_B.dh, session_id = SHA-256(ezcoder/v3/sid ‖ sorted(IK_A.dh, IK_B.dh) ‖ EK_A)[:8].
The initiator's messages carry 0x01 ‖ IK_A₃₂₀₀ ‖ EK_A₃₂ ‖ kem_ct₁₀₈₈ ‖ sig(ezcoder/v3/hs ‖ EK_A ‖ kem_ct ‖ IK_B.dh ‖ IK_B.kem ‖ sid)₃₃₇₃ ‖ ratchet message
until a reply arrives (~10 KB of text), then 0x02 ‖ sid ‖ ratchet message (~120 bytes + message).
Ratchet: X25519 DH ratchet; KDF_RK = HKDF-SHA256(salt = RK, ikm = DH, info = ezcoder/v3/rk, 64 B);
KDF_CK = HMAC-SHA256(CK, 0x02) → CK′, HMAC-SHA256(CK, 0x01) → MK; message key → ChaCha20-Poly1305 key ‖ nonce via
HKDF-SHA256(MK, info = ezcoder/v3/mk); AAD = session_id ‖ header (ratchet_pub₃₂ ‖ PN ‖ N); MAX_SKIP = 1000; state
is only committed after the AEAD tag verifies. The ratchet is implemented in-house (≈ 120 lines, straight from the spec).
What post-quantum covers, honestly. Sealed messages: confidentiality holds as long as either X25519 or ML-KEM-768 is unbroken; authenticity as long as either Ed25519 or ML-DSA-65 is. Sessions: the initial secret SK is hybrid, so a quantum attacker recording traffic today cannot recover a session from its handshake — but the per-message DH ratchet steps are X25519 only (exactly like Signal's PQXDH deployment). A future quantum attacker who also obtains a ratchet state could therefore follow the session from that point; there is no periodic KEM re-encapsulation (deliberately left out to keep the design small). Start a new session now and then if that matters to you.
Other limits / threat model. Long-term keys live in keys.json (optionally passphrase-wrapped with scrypt n=2¹⁵ +
ChaCha20-Poly1305) and ratchet state in sessions.json; whoever reads those can impersonate you and read future
session messages and all sealed messages ever sent to you (sealed mode has no forward secrecy). Sealed messages are
signed, so they are not deniable. The handshake has no one-time prekeys. Nothing protects metadata. ML-KEM / ML-DSA
come from OpenSSL through cryptography and are checked against the official FIPS 203 / 204 known-answer vectors in
the test suite, but this application has not been audited; the construction is documented above so it can be.
- The Windows taskbar shows the app's own icon. The taskbar button takes its icon from the process's Application User Model ID rather than from the window, and with none of its own the process was grouped under whatever launched it and wore that program's icon. One is now set before any window exists, and it carries no version number so a pinned button survives an upgrade.
- Hybrid post-quantum: ML-KEM-768 mixed into every key agreement (sealed messages and the session handshake),
ML-DSA-65 added to every signature. New prefixes
ezid3:/ezc3:/ezs3:, key file version 3. Nothing older is read — decrypt anything important with the old version first. Identities are now ~4.3 KB, messages ~10 KB. - Known-answer tests from the FIPS vectors; tests for tampering with each signature and with the KEM ciphertext.
- QR code now encodes the fingerprint (the identity no longer fits); identity shown in a copyable text box and can be saved to a file; whitespace / line breaks inside pasted identities and messages are ignored.
- X25519 / Ed25519 / ChaCha20-Poly1305 / HKDF replaced secp256k1 ECIES (
eciespy); messages became signed and bound to the recipient; Double Ratchet sessions; contacts; passphrase-protected key file; PySide6 GUI (was tkinter); CLI; tests; PyInstaller builds. 1.x had no authentication at all: anyone could forge a message "from" anyone.
pip install -r requirements.txt pyinstaller && python build.py → dist/ezcoder-<version>-<os>-<arch>.
Tests: python -m pytest tests. EZCODER_SELFTEST=1 runs the roundtrips headlessly (used by CI on the frozen build).