| Version | Supported |
|---|---|
| 2.1.x | Yes |
| 2.0.x | Yes |
| < 2.0 | No |
If you discover a security vulnerability in MORPHEUS, please report it responsibly. Do not open a public GitHub issue for security bugs.
- Email: Send a detailed report to 404securitynotfound@protonmail.ch
- Subject line:
[SECURITY] MORPHEUS — <brief description> - Include:
- Description of the vulnerability
- Steps to reproduce
- Affected version(s)
- Impact assessment (what an attacker could achieve)
- Suggested fix (if you have one)
- Acknowledgement within 48 hours
- Status update within 7 days
- Fix or mitigation for confirmed vulnerabilities within 30 days
- Credit in the changelog and release notes (unless you prefer anonymity)
The following are in scope:
- Cryptographic weaknesses (key derivation, cipher usage, nonce handling)
- Authentication bypasses (AEAD tag manipulation, format parsing bugs)
- Memory safety issues (key material leaking to swap, inadequate zeroing)
- Information leaks (plaintext length, timing side-channels)
- Key material lifecycle issues (shared secrets not zeroed, intermediate leaks)
- Format vulnerabilities (header downgrade, reserved byte manipulation)
- Dependency vulnerabilities in
cryptography,argon2-cffi,pqcrypto
The following are out of scope (documented in the Threat Model):
- Compromised endpoints (malware, keyloggers, hostile root)
- Python
strimmutability (fundamental language limitation) - Clipboard history managers retaining data beyond our control
- Denial of service via resource exhaustion (KDF is intentionally slow)
| Date | Scope | Findings |
|---|---|---|
| 2026-02-06 | Full code review (v2.0) | 17 findings (2 critical, 2 medium, 3 low, 3 info, 7 positive) — all remediated |
| 2026-02-07 | Cryptographic deep review | 7 findings (2 high, 3 medium, 2 low) — all remediated |
| 2026-02-08 | External review + independent audit | 4 external findings + 21 audit findings — remediated in v2.0.2 |
| 2026-02-08 | Privacy/crypto/ethics review + security hardening | 12 findings (4 crypto, 5 privacy, 3 ethical) + 6 hardening findings — remediated; see CHANGELOG entries 2.0.2 and 2.0.4 |
Review 1 — Full code review of v2.0 architecture:
- [CRITICAL]
secure_zero()operated on immutable copies, not actual key material - [CRITICAL] Password remained as immutable
strthrough the pipeline - [MEDIUM]
secure_keycontext manager yielded immutablebytescopy - [MEDIUM] Payload length not validated before slicing (index errors)
- All key paths now use mutable
bytearraywithctypes.memsetzeroing
Review 2 — Cryptographic deep review by domain expert:
- [HIGH] KEM shared secret (
kem_ss) was never zeroed after HKDF combination - [HIGH]
_combine_with_kemleaked concatenated key material as immutablebytes - [MEDIUM] Reserved header bytes (4-5) excluded from AAD, not validated
- [MEDIUM] HKDF info strings lacked application-specific domain separation
- [MEDIUM] No file encryption capability
- [LOW]
secure_zeroused Python byte-by-byte loop instead ofctypes.memset - [LOW] KEM ciphertext length=0 not rejected (potential PQ bypass)
| Primitive | Standard | Implementation |
|---|---|---|
| AES-256-GCM | NIST SP 800-38D | cryptography (OpenSSL) |
| ChaCha20-Poly1305 | RFC 8439 | cryptography (OpenSSL) |
| Argon2id | RFC 9106, OWASP 2024 | argon2-cffi |
| Scrypt | RFC 7914 | cryptography (OpenSSL) |
| HKDF-SHA256 | RFC 5869 | cryptography |
| ML-KEM-768 | FIPS 203 | pqcrypto (optional) |
The pipeline manages all key material as mutable bytearray buffers and
zeroes them via ctypes.memset after use. However, several underlying
library APIs require immutable bytes arguments, creating short-lived
copies that cannot be zeroed by the application:
| Call site | Library API | Copy created |
|---|---|---|
Argon2idKDF.derive() |
hash_secret_raw(secret=bytes(...)) |
Password copy |
ScryptKDF.derive() |
Scrypt.derive(bytes(...)) |
Password copy |
AES256GCM.encrypt/decrypt() |
AESGCM(bytes(key)) |
Key copy |
ChaCha20Poly1305Cipher.encrypt/decrypt() |
ChaCha20Poly1305(bytes(key)) |
Key copy |
_derive_keys() |
HKDFExpand.derive(bytes(master)) |
Master key copy |
_combine_with_kem() |
HKDF.derive(bytes(combined)) |
Password key + KEM shared secret copy (hybrid PQ only) |
The _combine_with_kem() site also builds its concatenation through
bytes(password_key) + kem_shared_secret, producing two further short-lived
immutable values before the mutable bytearray is created. The bytearray
itself is zeroed in a finally block; the immutable copies cannot be.
These copies persist on the Python heap until garbage collection. This is a
fundamental limitation of Python and the cryptography library's API design.
For absolute memory safety, a C or Rust implementation operating directly on
mutable buffers would be required.
Every 0600 file mode this documentation refers to — the ML-KEM secret key
written by --generate-keypair, the ~/.morpheus/config.toml written by
--save-config, and encrypt/decrypt output files — is applied with
os.chmod(path, 0o600).
On Windows that call does not do what it does on POSIX. It toggles only the
read-only attribute; it does not create an owner-only ACL. os.stat() reports
mode 0o666 on those files, and their real protection is whatever NTFS ACL
they inherit from the parent directory. Inside a user profile directory that
inheritance is normally restricted to that user and to Administrators, so the
practical exposure on a single-user machine is usually low — but MORPHEUS
neither sets nor verifies it, so it is not a guarantee this tool makes.
Consequences on Windows:
- Do not rely on the documented mode. Treat the secret key file and the config as protected only by the directory they sit in.
- Write
--generate-keypair --outputto a path inside your own user profile, not to a shared or world-writable location such asC:\Temp. - On a multi-user machine, an Administrator can read these files. That is also
true on POSIX for
root, but on Windows the set of principals is wider and is decided by inherited ACLs rather than by this tool.
The CI matrix runs Windows and the test suite passes there; the two POSIX-mode assertions are skipped on Windows rather than silently weakened, because the property they assert genuinely does not hold. Hardening Windows with an explicit ACL is a possible future change, not current behaviour.
The ML-KEM-768 post-quantum layer is provided by the pqcrypto community
package (0.4.0), which wraps PQClean, not liboqs. Verified by inspecting
the installed extension modules: PQCLEAN_MLKEM768_CLEAN_* symbols are
present and no liboqs/OQS_ symbols appear anywhere in the package.
This distinction is worth stating precisely, because it cuts both ways. liboqs
has a published third-party review (Trail of Bits, 2025) that this dependency
does not inherit; liboqs also carries an explicit upstream warning against
production use, which likewise does not apply here. PQClean and pqcrypto have
no equivalent public audit at all.
So, plainly:
- Has not undergone FIPS 140-3 validation
- Has not been independently audited by a third party
- Is maintained by a small open-source community
- Is an optional dependency. The default password-only path does not use it and is already quantum-resistant; see the README
The hybrid design ensures that overall security is never weaker than the password-based symmetric layer alone. ML-KEM-768 is an additional defense-in-depth layer, not the sole protection mechanism.
Format v3 includes an 8-byte key-check value: HMAC-SHA256(key, "morpheus-key-check")[:8].
This is verified before attempting AEAD decryption, providing a clear
"incorrect password" error instead of a generic InvalidTag.
Security implications: The key-check creates a distinguishing oracle — an attacker can tell whether a password guess is wrong (key-check mismatch) vs. whether the ciphertext is tampered (AEAD failure). This is an intentional UX tradeoff:
- Brute-force impact: Negligible. The key-check is computed from the KDF-derived key, so an attacker must still complete the full KDF computation for each guess. The key-check adds no shortcut.
- Truncation: 8 bytes (64 bits) provides 2^{-64} false-positive rate — effectively zero for password-checking purposes.
- Information leakage: Reveals only pass/fail, same as any AEAD scheme. The truncated HMAC is a PRF output and does not leak key material.
For deployments requiring indistinguishable error behavior (e.g., plausible
deniability use cases), v2 format can be used — it returns InvalidTag
for both wrong password and tampering.
Format v4 (the default for new encryptions) replaces the 8-byte check with a full 32-byte commitment, giving roughly 128 bits of committing security instead of ~32. It is a domain-separated SHA-256 over the length-prefixed key material — both subkeys in chained mode, since committing to the first alone would leave the second free.
It binds key material and nothing else, deliberately. The first draft followed CTX (Chan and Rogaway, ESORICS 2022) and hashed the nonces, AAD and KEM prefix in as well. An adversarial review showed that broke something v3 got right: this value is checked before the AEAD, so binding fields the AEAD tag already authenticates meant tampering with a nonce or a header byte failed here rather than at the tag, and was reported to the user as an incorrect password.
The division of labour is now clean. The commitment answers is this the right key, which an AEAD cannot. The tag answers has anything been modified, covering the nonce implicitly and the header, salt and KEM ciphertext through the v4 AAD. Neither duplicates the other, so each failure keeps its own error.
Committing to the key alone is sufficient against the attack that motivates this: a ciphertext opening under two passwords requires two keys with the same 32-byte commitment, which is 2^128 work.
The rest of this section describes the v2 and v3 situation, which still applies to ciphertexts written by those versions and is why v4 exists.
The point above about PRF output is true, and it answers the wrong question. PRF security says the value looks random to someone who does not know the key. Commitment security says it is hard to find two keys producing the same value — and that is the property under attack in the literature on password-based encryption.
Neither AES-GCM nor ChaCha20-Poly1305 is a committing AEAD. Efficient key multi-collision attacks against both are published (Len, Grubbs and Ristenpart, USENIX Security 2021), together with tooling that makes each decryption land as a plausible file in a real format (Albertini et al., USENIX Security 2022). RFC 9771 §4.3.3 names password-based encryption as an application requiring key commitment.
The 8-byte key-check is the only commitment-shaped value in the format. By the size relation in Bellare and Hoang (CRYPTO 2024), 64 bits of expansion gives roughly 32 bits of committing security. It also commits to the first encryption key alone — not the nonces, not the AAD, not the ML-KEM ciphertext, and not the second key in chained mode.
What this does and does not mean, stated honestly:
- There is no partitioning oracle here. MORPHEUS has no server and no queryable decryption endpoint, so the remote password-recovery result in that literature does not apply, and we do not claim otherwise.
- The applicable risk is deception by whoever created the ciphertext: a file crafted to decrypt to two different plausible documents under two different passwords. This is irrelevant if you encrypted the file yourself, which is the dominant use. It matters if you accept ciphertext from someone else and treat "it decrypted" as proof of origin — evidence handling, moderation, or scanning pipelines.
- Your strong Argon2id default does not bound this attack, because the attacker writes the header and decrypt rebuilds the KDF from it. The header-legal floor is far cheaper than the default.
For v2 and v3 ciphertexts, do not rely on a successful decryption as proof that a third party's ciphertext has only one plaintext. Re-encrypt anything you rely on that property for; v4 output carries the full commitment.
One thing v4 does not fix, stated so it is not mistaken for solved: tampering with the salt or the ML-KEM ciphertext is detected, but is still reported as an incorrect password. Altering either changes the derived key, so the commitment check fails for a legitimate reason and the code cannot tell the two cases apart. Distinguishing them needs an independently keyed MAC over the whole ciphertext, which v4 does not add.
When encrypting files, the encrypted envelope includes the original filename
(basename only) by default. This is encrypted alongside the file data, so it
is not visible without the password. However, once decrypted, the filename
is revealed. Use --no-filename to omit the original filename from the
envelope for maximum privacy.
The --pad flag pads plaintext to discrete size buckets (256B, 1K, 4K, 16K,
64K, then 64K multiples). This hides the exact plaintext length but still
reveals which bucket the data falls into.
For maximum privacy, the --fixed-size flag pads all ciphertexts to exactly
64 KiB regardless of input length. This eliminates length-based traffic
analysis entirely — all messages are indistinguishable by size. Inputs larger
than ~64 KiB cannot use fixed-size mode (use --pad instead).
The --check-leaks flag uses the Have I Been Pwned
Pwned Passwords API with k-anonymity:
- Your password is SHA-1 hashed locally
- Only the first 5 characters of the hash (out of 40) are sent to the API
- The API returns all hash suffixes matching that prefix (~500 results)
- Your client checks locally whether your full hash is in the returned set
Privacy: The actual password never leaves your machine. The 5-character prefix maps to ~10 million possible passwords, providing strong k-anonymity. The API operator cannot determine which password you checked.
Network: This is the only feature that makes network connections. It is strictly opt-in — never enabled by default. If the network is unavailable, encryption proceeds with a warning.
Standard password validation requires mixed character classes (uppercase,
lowercase, digits, special characters). The --passphrase flag switches
to word-based validation instead:
- Requires at least 4 words separated by spaces, hyphens, or underscores
- Requires at least 20 characters total length
- Does not require digits, uppercase, or special characters
- Scores based on word count, uniqueness, and average word length
This accepts high-entropy passwords like correct horse battery staple that
the standard checker would reject. The entropy of a 4-word passphrase from
a 7776-word diceware list is ~51 bits; 6 words yields ~77 bits.
The --save-config flag writes user preferences to ~/.morpheus/config.toml
with file permissions 0600 (owner read/write only) on POSIX; see
File permissions are POSIX-only (Windows) above. The config file stores
only non-sensitive settings (cipher choice, KDF choice, boolean flags). It
never stores passwords, keys, or ciphertext.
CLI arguments always override saved preferences. The config is loaded at startup and applied as defaults only for unset arguments.
AES-256-GCM uses a random 96-bit nonce. Under the birthday bound, the probability of a nonce collision reaches 2^{-32} (~1 in 4 billion) after approximately 2^{32} encryptions with the same key. Since each encryption in MORPHEUS uses a fresh random salt (producing a unique derived key), the effective nonce space resets per message. A collision only matters if the same derived key is reused with the same nonce, which requires both the same password AND the same salt — a probability of 2^{-128}.
Bottom line: For the expected use case (interactive encryption of individual messages/files), nonce collision risk is negligible. If you need to encrypt more than ~4 billion messages with the same password, use cipher chaining or a different nonce scheme.
See the Security Design section in the README for the full threat model, including what this tool does and does not protect against.