Quantum-resistant encryption for text and files.
A specified, versioned ciphertext format and its reference implementation.
AES-256-GCM · ChaCha20-Poly1305 · ML-KEM-768 · Argon2id
Quick Start · Format Specification · Dice for Seed Phrases · Full Guide · Security Policy · Changelog · Contributing
Most encryption tools make you choose: easy to use or cryptographically serious. MORPHEUS does both.
Every encryption it produces is already quantum-resistant — with no optional package, no key ceremony, and nothing to configure. That is a property of the defaults, not of an add-on. The short reason: the algorithms a quantum computer actually breaks are the ones MORPHEUS never uses, and the ones it does use lose at most half their strength, which still leaves them out of reach. The full reason is below. The whole point is that you do not have to do anything to get it.
The format is written down. docs/FORMAT.md specifies every
byte of it, well enough to build an independent implementation without reading
this source. tests/vectors/ holds stored ciphertexts that any implementation
must decrypt to claim compatibility, and the suite includes a second decryptor
written from that document alone, so the specification cannot quietly drift away
from the code.
What sets it apart:
- Quantum-resistant by default, not a mode you have to find and enable
- Cipher chaining: AES-256-GCM then ChaCha20-Poly1305 with independent keys
- Self-describing authenticated format: the settings block at the front of every ciphertext (which cipher, which password-stretching function, and its settings) is sealed by the same tamper tag as the data itself, so an attacker cannot edit it to force a weaker setting
Two terms used throughout. A KDF is the deliberately slow, expensive function that turns your password into a key. An AEAD cipher encrypts and tamper-proofs in one step, producing a short tag that fails loudly if a single bit changed.
- Optional ML-KEM-768 (FIPS 203) as a second, asymmetric factor: the holder of the secret key still needs the password too, so guessing the password alone is not enough
On the wording. "Post-quantum" is often used to mean "uses a lattice KEM". It is used here in the sense that matters to you: the ciphertext resists an attacker with a quantum computer. Symmetric encryption with a 256-bit key already does. ML-KEM adds a different property, described below.
| MORPHEUS | age | gpg | openssl enc | |
|---|---|---|---|---|
| Quantum-resistant password mode | Yes | Yes | Yes | Yes |
| Quantum-resistant recipient mode | ML-KEM-768 (FIPS 203), optional | No — X25519 | No — RSA/ECC | n/a |
| Cipher chaining | AES + ChaCha | -- | -- | -- |
| File encryption | Up to 100 MiB (any type) | Yes | Yes | Yes |
| Memory protection | Best-effort ctypes.memset zeroing of key buffers |
-- | pinentry | -- |
| Self-describing format | Versioned header, fully AAD-authenticated | Yes | Yes | -- |
| Written format specification | FORMAT.md + conformance vectors | Yes | Yes (RFC 4880) | -- |
| Key commitment | 32-byte, since format v4 | -- | -- | -- |
| KDF | Argon2id / Scrypt | scrypt | S2K | PBKDF2 |
git clone https://github.com/404SecNotFound/Morpheus.git
cd Morpheus && pip install -r requirements.txt
# Encrypt some text
python morpheus.py -o encrypt --data "sensitive text"
# Encrypt a file
python morpheus.py -o encrypt -f secret.pdf
# Every flag, with worked examples
python morpheus.py --helpYou do not need this for quantum resistance — every encryption above already has it.
pip install pqcryptois only for--hybrid-pq, the mode that adds a second, independent factor on top of the password. See Why quantum resistance does not depend on ML-KEM.
- You provide text or a file and a strong password
- The password is stretched through Argon2id (memory-hard: 64 MiB, t=3, p=4) into a 256-bit key
- Your data is encrypted with AES-256-GCM (authenticated encryption)
- The output is a single base64 string you can store anywhere
Every encryption produces different output — even for identical inputs — because a fresh random salt and nonce are generated each time.
Choose your protection level:
| Mode | What Happens | Best For |
|---|---|---|
| Single cipher | AES-256-GCM or ChaCha20-Poly1305 | Everyday encryption |
| Cipher chaining | AES-256-GCM then ChaCha20 with independent keys | Defense against single-algorithm compromise |
| Hybrid PQ | Password key + ML-KEM-768 shared secret combined via HKDF | A second factor: the recipient needs the ML-KEM secret key and the password. Not recipient-only encryption |
| Maximum | Chaining + Hybrid PQ (all layers) | Highest assurance |
All four are quantum-resistant. Hybrid PQ is not the one that makes that true — see below.
How cipher chaining works under the hood
Your password derives a master key via Argon2id. That master key is expanded through HKDF into two independent 256-bit subkeys — one for AES-256-GCM, one for ChaCha20-Poly1305. Your data is encrypted with AES first, then the AES ciphertext is encrypted again with ChaCha. An attacker must break both algorithms to recover your data.
How hybrid post-quantum works under the hood
Password ──> Argon2id ──> password_key (32 bytes)
|
ML-KEM-768 encapsulate ──> kem_shared_secret (32 bytes)
|
HKDF(password_key || kem_shared_secret) ──> final_key
|
AES-256-GCM
The encryption key is derived from both your password and a lattice-based shared secret. An attacker must break Argon2id (brute-force your password) and ML-KEM-768 (solve the Learning With Errors problem).
Because the two are combined with an AND, the result is at least as strong as the stronger factor. An attacker who does not hold the ML-KEM secret key cannot get in by guessing the password, however weak it is — which is precisely what this mode buys you over a password alone.
What it does not buy you is quantum resistance, because you already had that: see Why quantum resistance does not depend on this.
This is the part most tools get wrong, so it is worth being exact.
A quantum computer threatens the two families of cryptography very differently:
| Effect of a large quantum computer | Used by MORPHEUS for | |
|---|---|---|
| Symmetric (AES-256, ChaCha20) | Grover's algorithm halves the effective key length. 256-bit becomes ~128-bit, which is still far beyond reach | Encrypting your data |
| Password hashing (Argon2id) | No known quantum shortcut. Memory-hardness is unaffected | Turning your password into a key |
| Classical asymmetric (RSA, X25519) | Shor's algorithm breaks it outright | Nothing. MORPHEUS does not use it |
Because MORPHEUS never relies on RSA or elliptic curves to protect your data, there is nothing in the default path for Shor's algorithm to break. A password and Argon2id and AES-256 is a post-quantum construction, and it always was.
So what is ML-KEM-768 for? It solves a different problem: encrypting to someone else without first sharing a password. Tools that offer this normally do it with X25519 or RSA — which is exactly the part a quantum computer breaks. MORPHEUS uses a lattice KEM instead, so the recipient mode is quantum-resistant too.
Put plainly:
- Just want your data safe from future quantum computers? Use MORPHEUS
normally. You already have it, and you never need
pqcrypto. - Want to send something to a colleague without agreeing a password first?
That is what
--hybrid-pqis for, and it needs the optional package.
The honest summary: post-quantum is the floor here, not the upsell.
# Interactive mode (prompts for operation, input, and password)
python morpheus.py -o encrypt
# Encrypt text
python morpheus.py -o encrypt --data "sensitive text"
# Encrypt with chaining + Scrypt
python morpheus.py -o encrypt --data "text" --chain --kdf Scrypt
# Encrypt a file (any type: text, binary, images, archives)
python morpheus.py -o encrypt -f document.pdf
# -> morpheus_ab12cd34ef56.enc (random name: the filename is not leaked on disk)
# Decrypt a file (the real name is restored from inside the ciphertext)
python morpheus.py -o decrypt -f morpheus_ab12cd34ef56.enc
# -> document.pdf
# Pipe from stdin
echo "secret" | python morpheus.py -o encrypt --data -
# Generate ML-KEM-768 keypair for hybrid PQ.
# Public key goes to stdout; secret key to a 0600 file (POSIX; on Windows see SECURITY.md).
python morpheus.py --generate-keypair --output my_pq_secret.key
# Hybrid PQ encrypt
python morpheus.py -o encrypt --data "text" \
--hybrid-pq --pq-public-key <base64-pk>
# Hybrid PQ decrypt
python morpheus.py -o decrypt --data "BAECAg..." \
--hybrid-pq --pq-secret-key-file my_pq_secret.key
# Use passphrase mode (no digits/specials required)
python morpheus.py -o encrypt --data "text" --passphrase
# Check password against breach databases before encrypting
python morpheus.py -o encrypt --data "text" --check-leaks
# Save your preferred settings for future sessions
python morpheus.py --save-config --cipher ChaCha20-Poly1305 --chain --pad
# Inspect a ciphertext without decrypting (no password needed)
python morpheus.py --inspect --data "BAECAA..."
python morpheus.py --inspect -f secret.encPasswords should always be entered interactively, so they cannot leak via
ps, shell history, or /proc. A deprecated -p/--password flag still accepts
one from argv for backward compatibility. It is hidden from --help, warns when
used, and will be removed. Do not use it.
All CLI flags
| Flag | Description |
|---|---|
-o, --operation |
encrypt or decrypt |
-d, --data |
Text to encrypt/decrypt. Use - for stdin |
-f, --file |
File to encrypt/decrypt |
--output |
Explicit output path (overrides defaults) |
--cipher |
AES-256-GCM (default) or ChaCha20-Poly1305 |
--kdf |
Argon2id (default) or Scrypt |
--chain |
Enable cipher chaining |
--pad |
Pad plaintext to hide exact length (bucket mode: 256B/1K/4K/16K/64K) |
--fixed-size |
Pad the plaintext to 64 KiB so every ciphertext is the same size. The base64 output is 87,508 characters in default single-cipher mode, not 64 KiB: header, salt, nonce, commitment, tag and the outer base64 all sit on top. Implies --pad |
--force |
Overwrite existing output files |
--allow-expensive-kdf |
Permit decrypting a ciphertext whose header asks for unusually expensive KDF settings. Off by default: the header is not authenticated until after the work is done, so a hostile file can otherwise spend minutes of CPU and hundreds of MiB |
--no-strength-check |
Skip password strength validation |
--no-filename |
Omit original filename from encrypted envelope |
--hybrid-pq |
Enable hybrid post-quantum |
--pq-public-key |
Base64 ML-KEM-768 public key. ~1,580 chars, so prefer the file form below |
--pq-public-key-file |
Path to a file holding the base64 public key. Preferred over --pq-public-key. --generate-keypair writes one as <secret-key-path>.pub |
--pq-secret-key |
Base64 ML-KEM-768 secret key. Discouraged: argv is readable by other local users |
--pq-secret-key-file |
Path to a file holding the base64 secret key. Preferred over --pq-secret-key |
--generate-keypair |
Generate an ML-KEM-768 keypair: public key to stdout, secret key to a 0600 file (POSIX only; on Windows the mode is not applied — see SECURITY.md) |
--passphrase |
Use passphrase-mode strength check (word-based, no digit/special requirement). Requires 4+ words and 20+ chars |
--check-leaks |
Check password against Have I Been Pwned breach database (k-anonymity, only 5 chars of SHA-1 sent). Requires network |
--save-config |
Save current cipher/KDF/flag preferences to ~/.morpheus/config.toml for future sessions |
--inspect |
Inspect a ciphertext header without decrypting (no password needed). Shows format, cipher, KDF, flags, sizes |
--benchmark |
Benchmark cipher and KDF performance, recommend optimal config |
--dice-entropy N |
Report how much entropy N fair dice rolls carry, and how many more reach 128 or 256 bits. Takes a count, never the rolls. Exit code 0 at or above the 128-bit floor, 1 below it, so a script can gate on it |
--dice-sides N |
Faces on the die used with --dice-entropy (default 6; use 2 for coin flips) |
--check-network |
List which interfaces currently report a live link, for setting up an air-gapped machine. Reads kernel link state only: it sends no packets and opens no sockets. Exit code 0 when nothing could carry traffic, 1 when something could, 2 where link state cannot be read at all. Linux only, and it cannot prove a machine is air-gapped |
--version |
Print the version and exit. Quote this when reporting an issue |
Passing any flag runs the CLI. Running python morpheus.py with no arguments launches the GUI.
See Rolling Dice for a Seed Phrase for what
--dice-entropy is actually for, and
Checking What Is Still Connected for
--check-network.
If you are generating a wallet seed with physical dice, this tells you when to stop rolling. That is its whole job.
On 30 July 2026 roughly 594 BTC moved out of about 500 addresses in 25 minutes. A COLDCARD firmware bug had routed seed generation through a software random number generator instead of the device's hardware one. Mk2 and Mk3 seeds ended up with roughly 40 bits of real search space against an intended 128, and Mk4, Q and Mk5 with roughly 72.
Users who had added at least 50 of their own dice rolls were not considered at risk, because the firmware mixed those rolls in with the device's own output. Physical dice survived a total failure of the vendor's generator.
The cause is worth knowing, because it was not a weak algorithm. Coinkite wrote
their own hardware generator and set MICROPY_HW_ENABLE_RNG = 0 to switch
MicroPython's path off. The guard that read that setting tested whether the name
was defined, not what it was set to, so setting it to zero enabled exactly
the thing it was meant to disable. That one-line confusion shipped in firmware
4.0.0 in March 2021 and stood for five years.
What has changed since. Fixed firmware now exists for every affected model: Mk2/Mk3 4.2.0 or later, Mk4/Mk5 standard 5.6.0 or later, Q standard 1.5.0Q or later, Mk4/Mk5 Edge 6.6.0X or later, Q Edge 6.6.0QX or later. Standard and Edge are separate tracks, so a higher Edge number is not automatically a fixed one. Updating does not repair a seed already generated: affected users have to migrate. The 594 BTC above was only the first sweep, and Galaxy Research put the confirmed total near 1,367 BTC across about 4,585 addresses by 2 August.
Sources: Coinkite advisory and technical backgrounder; CoinDesk on the first sweep. Check the vendor's pages rather than this summary before acting on it.
A die is not software. Nobody can push a bad update to it, it has no supply chain, and you can watch it with your own eyes. That is the entire argument for rolling, and for counting the rolls correctly.
Roll one die 100 times. Write down each number in order. Stop.
Hand those 100 numbers to your hardware wallet and it builds the strongest 24-word seed a phrase of that length can hold. The dice are the raw unpredictability; the wallet does the conversion. Before you start typing, check you rolled enough:
python morpheus.py --dice-entropy 100 Verdict: Strong. Clears 256 bits.
Nobody can guess this, at any budget.
You type the number 100, not your rolls. MORPHEUS never sees them.
Some procedures and devices ask for 99, not 100. Roll 100 anyway. The extra roll costs seconds and there is no downside to being over.
99 rolls give 255.9 bits, not the 256 that guidance elsewhere rounds it to.
--dice-entropy reports the measured figure and names the 0.1-bit gap rather
than rounding into agreement. The difference has no practical consequence, but
100 lands you cleanly above 256 instead of a hair under it.
| Rolls (d6) | Entropy | |
|---|---|---|
| 49 | 126.7 bits | below the floor |
| 50 | 129.2 bits | clears 128 |
| 99 | 255.9 bits | just short of 256 |
| 100 | 258.5 bits | clears 256 |
- Take one die. Casino dice are ideal: sharp edges, flat faces and flush pips make them fair, where cheap rounded dice are slightly biased.
- Roll it. Write the number down. Roll again. Write it down.
- Keep going until you have 100 numbers on paper, in the order you rolled them.
- Type those numbers into your air-gapped hardware wallet's dice entry, and nowhere else. Not every wallet offers this. It usually appears during new-wallet setup, under a name like "dice rolls" or "add entropy". If yours does not have it, stop here: converting the rolls yourself on a general-purpose computer is worse than not using dice at all.
- Destroy the paper once the wallet has shown you the seed phrase and you have written that down. Until then those 100 numbers are your seed, and anyone who reads them can rebuild your wallet. Shred or burn it. Do not photograph it, and do not keep it as a backup: the seed phrase is the backup.
Five rules while you roll:
- One die at a time. Do not throw a handful and read them together. Order is half the secret, and a batch read in sorted order throws most of it away.
- Write every result down, in order.
- Never re-roll. If you get six 6s in a row, write six 6s. Re-rolling a result because it "looks wrong" destroys the randomness you just made.
- Nobody watching. No camera. No phone on the table.
- Never type the rolls into a computer. Not into MORPHEUS, not into a dice-to-seed web page, not even an offline one.
What "bits" means here. A bit is one coin flip's worth of unpredictability. Two bits is four possible outcomes, ten bits is 1,024, and each bit you add doubles the number of guesses an attacker must work through. 128 bits is the floor below which a well-funded attacker can search. 256 bits is the most a 24-word seed phrase can hold, and nothing on Earth searches it.
| You want | Entropy | Six-sided dice | Coin flips |
|---|---|---|---|
| 12-word seed | 128 bits | 50 rolls | 128 flips |
| 24-word seed | 256 bits | 100 rolls | 256 flips |
Each roll of a fair six-sided die is worth log₂(6) ≈ 2.585 bits. A coin is worth exactly 1, which is why coins take two and a half times as long.
Rolling only 50 and asking for 24 words is the trap that matters. You get a valid 24-word phrase carrying 129 bits, not 256. It looks like a 24-word seed and has the strength of a 12-word one. Hashing does not rescue it: SHA-256 over 50 rolls returns 256 bits of output carrying 129 bits of entropy.
Rolling more than 100 does not help either. 300 rolls carries 775.5 bits, but a 24-word seed holds 256 and the format discards the rest. The tool says so:
Verdict: Strong. Clears 256 bits.
Nobody can guess this, at any budget.
100 rolls was enough; the other 200 added nothing,
because a 24-word seed holds only 256 bits.
It does not generate seeds, and it should not. It generates nothing, derives nothing and stores nothing.
Seed generation belongs on an air-gapped device with a screen you trust. Solving a bad-generator problem by typing your seed into a laptop sitting next to a browser trades a known weakness for a worse one.
The figure is an upper bound. It holds only if the rolls were fair, independent, ordered and private, and software counting rolls cannot check any of those. The tool prints all four every run rather than just a number, because the number is worthless without them.
The step before rolling dice is unplugging the machine, and the usual way to confirm that is to open a browser and see whether a page loads. On a machine you are about to generate a seed on, that is exactly the wrong move.
python morpheus.py --check-networkMORPHEUS Network Check
============================================
eth0 ethernet no carrier down
lo loopback CARRIER unknown
wlan0 wireless no carrier down
No interface currently reports a carrier.
This reads link state only. It sends no packets and opens no sockets, which
is deliberate: probing the network is the one thing an air-gapped machine
must not do.
It cannot tell you this machine is air-gapped. It does not see a phone about
to be tethered, a Bluetooth connection, a virtual machine's host bridge, or
a cable plugged back in a minute from now. It cannot see whether the machine
was already online earlier, which is what matters most: an air gap stops
data leaving over the wire, not something that arrived before the gap.
Loopback shows a carrier and is never counted: it goes nowhere.
The obvious way to answer "am I online" is to try reaching something. That sends
the packet an air-gapped user must not send, and it announces that MORPHEUS is
running, when, and from which address. So the question is narrowed to one the
machine can answer by itself: is any interface in a state where traffic could
leave. That is read from /sys/class/net, which the kernel already maintains.
--check-network opens no sockets, resolves no names and starts no
subprocesses. A test parses the module and fails the build if it ever imports
socket, urllib, subprocess or anything similar, because this is the kind
of property that erodes quietly during a refactor.
No absence of carrier proves that. It cannot see a phone about to be tethered, a Bluetooth connection, a virtual machine's host bridge, or a cable pushed back in a minute from now. Most importantly it cannot see whether the machine was online earlier, which is what usually matters: an air gap stops data leaving over the wire, it does nothing about something that arrived before the gap.
So the output reports what was observed and names its own blind spots. There is no green light meaning "you are safe", because the software cannot back that sentence, and a false sense of security is at its most expensive in the minutes someone is generating a seed.
| Code | Meaning |
|---|---|
0 |
No interface was observed carrying traffic |
1 |
At least one interface could carry traffic |
2 |
Link state could not be read on this platform |
2 exists so a setup script cannot read "unsupported" as a clean result. Link
state comes from /sys/class/net, which only Linux provides; on macOS and
Windows the check declines rather than guessing, and you disconnect the cable
and turn off Wi-Fi by hand.
| Threat | Protection |
|---|---|
| Offline password brute-force | Argon2id, 64 MiB memory-hard per guess (t=3, p=4). See the note below on cost |
| Future quantum computers | The default path already covers this. Argon2id has no known quantum shortcut, and AES-256 / ChaCha20 retain ~128-bit security against Grover. Optional ML-KEM-768 (FIPS 203) adds a second, asymmetric factor — see below |
| Single-algorithm compromise | Cipher chaining (two independent algorithms, independent keys) |
| Memory forensics | Best-effort ctypes.memset zeroing of key buffers after use. See limitations |
| Ciphertext tampering | AEAD authentication tag (16 bytes) |
| Algorithm downgrade | Header authenticated as AAD (v4 also binds salt and KEM ciphertext) |
| Ciphertext opening to two plaintexts | v4 32-byte key commitment (~128-bit committing security) |
On brute-force cost. The defence is memory-hardness, not wall-clock time. Each guess costs 64 MiB, which is what constrains large-scale parallel attack on GPUs and ASICs. Do not assume a fixed seconds-per-guess figure: on a 2024-class laptop a single derivation takes roughly 30 ms, so a strong password remains essential. Raise
time_costif your threat model needs a higher per-guess cost.
| Limitation | Why |
|---|---|
| Compromised endpoint (malware, keylogger) | No user-space tool can defend against a hostile OS |
Python str immutability |
Password briefly exists as an immutable string before bytearray conversion; GC timing is unpredictable |
| Immutable copies inside crypto bindings | secure_zero clears our own bytearray buffers, but OpenSSL and the argon2 bindings receive immutable bytes copies that cannot be zeroed |
| Swap to disk | Key buffers are not mlocked. On a machine under memory pressure they may be paged to swap. Use full-disk encryption |
| Shell history | --data "secret" puts the plaintext in your shell history and in the process list. Pipe from stdin instead when that matters |
| Setting | Value | Rationale |
|---|---|---|
| Argon2id | t=3, m=64 MiB, p=4 |
OWASP 2024 minimum. Memory-hard, resists GPU/ASIC. The id variant resists both side-channel and brute-force |
| AES-256-GCM | 256-bit key, 96-bit nonce | NIST standard, AES-NI accelerated. 256-bit key gives ~128-bit post-quantum margin via Grover |
| ChaCha20-Poly1305 | 256-bit key, 96-bit nonce | Constant-time in software, preferred without AES-NI. Same quantum margin |
| ML-KEM-768 | FIPS 203, Category 3 | Balances post-quantum security (~AES-192) with practical key sizes. Category 5 doubles sizes for marginal gain |
| Scrypt | n=2^17, r=8, p=1 |
RFC 7914, ~128 MiB. Offered where Argon2 is unavailable |
| Salt | 16 bytes | Standard for Argon2id/Scrypt. Prevents rainbow tables |
| Nonce | 12 bytes | Standard for AES-GCM and ChaCha20. Random nonces safe for expected use |
Be precise about this rather than claiming a blanket guarantee.
| Path | Writes to disk? |
|---|---|
CLI text mode (--data) |
No. Input comes from argv or stdin, output goes to stdout |
CLI file mode (--file) |
Yes, by design. Encrypt writes morpheus_<random>.enc, so the original filename is not exposed on disk; decrypt restores the real name from inside the ciphertext. --output overrides both |
--save-config |
Yes. Writes ~/.morpheus/config.toml (mode 0600 on POSIX; not applied on Windows — see SECURITY.md) |
| Anything else | No temporary plaintext files are created |
Output files inherit your umask. On a shared machine, set a restrictive umask before decrypting sensitive files.
docs/FORMAT.md is the normative specification. It defines every byte of v2, v3 and v4, the AAD construction, the frozen key-derivation labels, the KDF parameters and the padding scheme, in enough detail to write an independent implementation without reading this source. What follows is a summary.
The format is self-describing: the header records which algorithms were used, so no out-of-band configuration is needed to decrypt.
| v2 | v3 | v4 (current) | |
|---|---|---|---|
| Header | 6 bytes | 18 bytes | 18 bytes |
| KDF parameters on the wire | No | Yes | Yes |
| Key verification | None | 8-byte truncated HMAC | 32-byte commitment |
| Commitment covers | n/a | the first key only | all key material |
| AAD covers | the header | the header | header + salt + KEM ciphertext |
| Hybrid combiner | hybrid-pq-v1 |
hybrid-pq-v1 |
binds the KEM ciphertext, encapsulation key and AAD, per NIST SP 800-227 §4.6.3 |
Payload: [salt][nonce(s)][KEM prefix if hybrid][key check][ciphertext + tag(s)]
v4 is the only version written. v2 and v3 still decrypt and always will.
Every header byte is authenticated, so modifying any of them causes decryption to fail. That is what prevents an algorithm-downgrade attack.
One limitation v4 does not remove: tampering with the salt or the KEM ciphertext is detected but still reports as a wrong password, because changing either changes the derived key. See SECURITY.md.
tests/vectors/ holds stored ciphertexts for all three versions with their
passwords and plaintexts. Decrypt all of them and you are compatible. They
are never regenerated, which is what makes them able to catch a silent format
break. tests/test_spec_conformance.py is a second decryptor written from
FORMAT.md alone, importing none of this implementation, so a format change that
does not reach the specification turns the suite red.
pip install pytest
python -m pytest tests/ -v632 tests across 13 test files:
| File | Scope |
|---|---|
test_ciphers.py |
AES-GCM + ChaCha20 roundtrips, NIST SP 800-38D AES-256-GCM vector, RFC 8439 vector, indistinguishability, wrong key/AAD/tampered data |
test_kdf.py |
Argon2id + Scrypt derivation, determinism, bytearray returns, salt generation |
test_formats.py |
Serialize/deserialize, flag combinations, version/reserved byte validation, AAD collision resistance |
test_pipeline.py |
All mode roundtrips (single/chained/hybrid/both), wrong password (InvalidTag), cross-compatibility, payload truncation, KEM length=0 bypass, header tampering |
test_memory.py |
secure_zero, SecureBuffer, secure_key context manager |
test_validation.py |
Password scoring (0-100), minimum requirements, edge cases |
test_config.py |
Preference load/save, allow-list validation, file mode |
test_fuzz.py |
Property-based fuzzing of the parser against hostile input (requires hypothesis) |
test_cli.py |
File encrypt/decrypt roundtrip (text + binary), path traversal prevention |
test_entropy.py |
Bits per roll for d2/d6/d20, the 128-bit floor and 256-bit target, rolls-needed arithmetic, verdict wording, CLI exit codes |
test_vectors.py |
Pinned v2/v3/v4 ciphertexts still decrypt to their recorded plaintext; tampered vectors rejected |
test_spec_conformance.py |
A second decryptor written from docs/FORMAT.md alone, importing nothing from this package, run against every stored vector. Catches a format change that never reached the specification |
test_netcheck.py |
Link-state parsing from a fake sysfs, loopback and virtual interface handling, an AST guard that the module imports no networking, and the refusal to call a quiet machine air-gapped |
Tests include NIST SP 800-38D and RFC 8439 reference vectors verified
against the cryptography library's validated implementations.
Morpheus/
├── morpheus_crypt/
│ ├── __init__.py # Package version
│ ├── __main__.py # Entry point; a bare invocation prints the help
│ ├── cli.py # The CLI: the reference implementation's interface
│ └── core/
│ ├── ciphers.py # AES-256-GCM, ChaCha20-Poly1305
│ ├── kdf.py # Argon2id, Scrypt
│ ├── pipeline.py # Orchestration: chaining, hybrid PQ, key lifecycle
│ ├── formats.py # Versioned binary format with AAD
│ ├── config.py # Persistent user preferences (~/.morpheus/config.toml)
│ ├── memory.py # ctypes.memset zeroing of key buffers
│ ├── validation.py # Password scoring, passphrase mode, breach detection
│ ├── entropy.py # Dice-roll entropy arithmetic (--dice-entropy)
│ ├── netcheck.py # Passive link-state reading (--check-network)
│ └── errors.py # MorpheusError hierarchy
├── tests/ # 632 tests (NIST/RFC vectors included)
│ └── vectors/ # The conformance suite: v2/v3/v4 known answers
├── docs/FORMAT.md # Normative wire-format specification
├── docs/USAGE.md # Full guide for technical and non-technical readers
├── SECURITY.md # Vulnerability disclosure policy
├── CHANGELOG.md # Version history
├── CONTRIBUTING.md # Contributor guide
├── .github/workflows/ci.yml # CI: Python 3.10-3.13 test matrix
├── .github/workflows/tag.yml # On a v* tag: version must match pyproject
├── pyproject.toml
├── requirements.txt
└── LICENSE # MIT
| Package | Purpose | Required |
|---|---|---|
cryptography |
AES-GCM, ChaCha20, Scrypt, HKDF | Yes |
argon2-cffi |
Argon2id key derivation | Yes |
pqcrypto |
ML-KEM-768 post-quantum KEM (community wrapper around PQClean, not FIPS-validated, no public audit) | Optional |
Python 3.10+
Two required packages, six installed in total once their own transitive dependencies are counted. That is deliberate for a cryptographic tool: every package here is something a user has to trust, and the list is short enough to read.
See CONTRIBUTING.md for guidelines. We welcome:
- Bug reports and security disclosures (see SECURITY.md)
- New cipher or KDF implementations
- Documentation improvements
- Test coverage expansion
MORPHEUS is provided as-is for educational and personal use. It has not undergone formal FIPS 140-3 validation or independent third-party audit. Do not rely on it as your sole protection for data subject to legal, regulatory, or compliance requirements (HIPAA, GDPR, PCI-DSS, etc.).
The authors are not responsible for data loss, unauthorized disclosure, or any damages resulting from the use of this software. There is no password recovery mechanism — if you forget your password, your data is permanently and irrecoverably lost.
Use of cryptographic software may be restricted or regulated in some jurisdictions. You are responsible for compliance with all applicable laws.
- No telemetry or analytics: MORPHEUS does not phone home or collect usage
data. The only network connection is opt-in breach checking (
--check-leaks), which uses k-anonymity and never sends your actual password. - Disk usage is narrow but not zero: text mode is entirely in-memory. File
mode writes only the ciphertext (or the decrypted original).
--save-configwrites~/.morpheus/config.toml. See What Touches the Disk. - Shell history: passing plaintext via
--datarecords it in your shell history and exposes it in the process list. Pipe from stdin instead when that matters. - Plaintext length: Without
--pad, ciphertext length reveals approximate plaintext length. Use--padfor length-hiding (pads to buckets: 256B, 1K, 4K, 16K, 64K). Bucket membership is still visible. - Ciphertext is identifiable: The versioned header (0x02/0x03/0x04) makes MORPHEUS ciphertexts recognizable. This tool does not provide plausible deniability or steganography — it is designed for confidentiality, not undetectability.
- Password as signal: A strong password (high entropy) may itself signal security awareness to an observer. This is inherent to password-based encryption.