mcrypt was deprecated in PHP 7.1 and removed in 7.2. Data encrypted with it
did not disappear with it, and OpenSSL will not read most of it, because mcrypt
supported block sizes and modes that OpenSSL never exposed.
This guide maps mcrypt calls onto cryptian. Every table here was verified
against the running library rather than transcribed from documentation. The
mode mappings in particular are checked by test/openssl.
Three mcrypt behaviours were implicit. Missing any one of them produces output that looks plausible and is wrong.
This is the single most common migration mistake. In mcrypt, the number is the block size, not the key size.
| mcrypt constant | block size | key sizes | is it AES? |
|---|---|---|---|
MCRYPT_RIJNDAEL_128 |
128 bit | 128 / 192 / 256 bit | yes |
MCRYPT_RIJNDAEL_192 |
192 bit | 128 / 192 / 256 bit | no |
MCRYPT_RIJNDAEL_256 |
256 bit | 128 / 192 / 256 bit | no |
AES fixed Rijndael's block size at 128 bit. MCRYPT_RIJNDAEL_256 is Rijndael
with a 256 bit block, which AES never standardised, so no OpenSSL build
implements it and aes-256-cbc will not decrypt it. AES-256 is
MCRYPT_RIJNDAEL_128 with a 32 byte key.
If your PHP used MCRYPT_RIJNDAEL_256, cryptian is one of the few ways left to
read that data.
mcrypt_encrypt() silently zero-padded the plaintext up to the block size. It
is not PKCS#7. Use padding.Null to reproduce it.
padding.Null and padding.Space record no length. They append a filler byte
and nothing else, so unpadding can only strip trailing filler and guess where
the message ended. Plaintext that legitimately ends in zero bytes or spaces
cannot be recovered exactly.
This is a property of the schemes themselves, not of libmcrypt or of this implementation. libmcrypt had no padding layer at all: PHP zero-padded on the way in and never stripped on the way out, leaving callers to trim the result by hand. These schemes are kept here because real systems still use them, not because they are sound.
Only the caller knows whether trailing zeros or spaces are data or filler, so
only the caller can resolve the ambiguity. If it matters, record the original
length alongside the ciphertext, or move to padding.Pkcs7, which stores the
padding length and round trips exactly.
Space padding comes from fixed width record traditions, where a field was
always full and shorter values were blank filled. Fixed width mainframe records
and SQL CHAR columns behave the same way. Use padding.Space when the legacy
format expected blanks rather than zeros.
Both schemes strip at most one block. Filler is never longer than the block, so that bound is correct, and it also limits how much genuine trailing data an unpad can consume when the plaintext really did end in filler bytes.
If the key was shorter than the algorithm wanted, mcrypt padded it with zero bytes instead of failing. cryptian does the same, so a short key produces the same result it did in PHP:
const short = Buffer.from('secret'); // 6 bytes
const padded = Buffer.concat([short, Buffer.alloc(10)]); // 16 bytes
// both produce identical ciphertextDo not rely on this for new code. Derive a full length key instead.
| mcrypt constant | cryptian | block | key sizes (bytes) |
|---|---|---|---|
MCRYPT_RIJNDAEL_128 |
algorithm.Rijndael128 |
16 | 16 / 24 / 32 |
MCRYPT_RIJNDAEL_192 |
algorithm.Rijndael192 |
24 | 16 / 24 / 32 |
MCRYPT_RIJNDAEL_256 |
algorithm.Rijndael256 |
32 | 16 / 24 / 32 |
MCRYPT_BLOWFISH |
algorithm.Blowfish |
8 | up to 56 |
MCRYPT_BLOWFISH_COMPAT |
algorithm.Blowfish + setEndianCompat(true) |
8 | up to 56 |
MCRYPT_CAST_128 |
algorithm.Cast128 |
8 | 16 |
MCRYPT_CAST_256 |
algorithm.Cast256 |
16 | 16 / 24 / 32 |
MCRYPT_DES |
algorithm.Des |
8 | 8 |
MCRYPT_3DES, MCRYPT_TRIPLEDES |
algorithm.Tripledes |
8 | 24 |
MCRYPT_TWOFISH |
algorithm.Twofish |
16 | 16 / 24 / 32 |
MCRYPT_GOST |
algorithm.Gost |
8 | 32 |
MCRYPT_LOKI97 |
algorithm.Loki97 |
16 | 16 / 24 / 32 |
MCRYPT_RC2 |
algorithm.Rc2 |
8 | up to 128 |
MCRYPT_SAFER64 |
algorithm.Safer with an 8 byte key |
8 | 8 |
MCRYPT_SAFER128 |
algorithm.Safer with a 16 byte key |
8 | 16 |
MCRYPT_SAFERPLUS |
algorithm.Saferplus |
16 | 16 / 24 / 32 |
MCRYPT_SERPENT |
algorithm.Serpent |
16 | 16 / 24 / 32 |
MCRYPT_THREEWAY |
algorithm.Threeway + setEndianCompat(true) |
12 | 12 |
MCRYPT_XTEA |
algorithm.Xtea |
8 | 16 |
MCRYPT_ARCFOUR |
algorithm.Arcfour |
stream | up to 256 |
MCRYPT_CRYPT |
algorithm.Enigma |
stream | 13 |
MCRYPT_WAKE |
algorithm.Wake |
stream | 32 |
MCRYPT_PANAMA |
algorithm.Panama |
stream | 32 |
SAFER64 and SAFER128 are the same cipher; the key length selects the
variant, so there is one Safer class. libmcrypt names these modules
SAFER-SK64 and SAFER-SK128, and that is what Safer reproduces.
MCRYPT_BLOWFISH_COMPAT is ordinary Blowfish with a different byte order.
Calling setEndianCompat(true) switches to it. Getting this wrong yields
ciphertext that is the right length and completely wrong.
MCRYPT_THREEWAY needs setEndianCompat(true) as well, and this one is easier
to get wrong. Blowfish has two mcrypt constants, so the flag maps to a choice
you already made in PHP. Threeway has only one, and the default here is the
byte order of the original 3-Way rather than the one libmcrypt used, so the
flag is required for every migration. Without it nothing decrypts and there is
no second constant to hint that a flag exists.
MCRYPT_IDEA and MCRYPT_RC6 are not available. Every other cipher mcrypt
shipped is present.
GOST does not fix its substitution boxes; they are a parameter. algorithm.Gost
reproduces the set libmcrypt used, which is what mcrypt data needs. Test data
published against other parameter sets, such as the one from R 34.11-94, will
not match, and that is a difference in parameters rather than a fault in either
implementation.
Serpent is roughly two orders of magnitude slower here than Rijndael. Its substitution is applied across all four words at once using masks derived from the substitution table, which is correct for any box and needs no hand written boolean expressions per box, but it is not as fast as an implementation with those expressions written out. If you are moving a large body of Serpent data and the rate matters, measure before committing to a maintenance window.
The mcrypt naming is not obvious and does not match OpenSSL. In mcrypt an n
prefix means the full block width, and the unprefixed name means 8 bit.
| PHP mode | cryptian | what it is | OpenSSL name |
|---|---|---|---|
MCRYPT_MODE_ECB, 'ecb' |
mode.ecb |
electronic codebook | aes-128-ecb |
MCRYPT_MODE_CBC, 'cbc' |
mode.cbc |
cipher block chaining | aes-128-cbc |
MCRYPT_MODE_CFB, 'cfb' |
mode.cfb |
8 bit cipher feedback | aes-128-cfb8 |
'ncfb' |
mode.ncfb |
full block cipher feedback | aes-128-cfb |
MCRYPT_MODE_OFB, 'ofb' |
mode.ofb |
8 bit output feedback | none |
MCRYPT_MODE_NOFB, 'nofb' |
mode.nofb |
full block output feedback | aes-128-ofb |
'ctr' |
mode.ctr |
counter | aes-128-ctr |
MCRYPT_MODE_STREAM, 'stream' |
use the algorithm directly | no mode wrapper | n/a |
Two traps here:
- OpenSSL's
aes-128-cfbis the full block variant, which is cryptian'sncfb, notcfb. PHP'sMCRYPT_MODE_CFBis the 8 bit one. - OpenSSL's
aes-128-ofbis cryptian'snofb. There is no OpenSSL equivalent for cryptian's 8 bitofb.
pcbc exists in cryptian but has no mcrypt or OpenSSL counterpart. libmcrypt
has no pcbc module at all, so there is no mcrypt data in that mode to migrate.
It is checked against a separate implementation of the published definition
rather than against mcrypt output.
If your data used MCRYPT_RIJNDAEL_128 in cbc, ecb, ncfb, nofb or
ctr, node's built-in crypto can read it directly with the OpenSSL name
above, provided you handle the null padding yourself. cryptian is required for
the 192 and 256 bit block sizes, the 8 bit cfb and ofb modes, and the
ciphers OpenSSL dropped.
Every cipher above, and every mode except pcbc, is tested against vectors produced by compiling libmcrypt itself and running its module, with keys spanning the full byte range. The modes are driven through libmcrypt's own mode modules at both an eight and a sixteen byte block. That matters more than agreement with any published test set, because the property this library needs is agreement with what mcrypt wrote.
Rijndael at 192 and 256 bit blocks has no other reference at all. AES fixed the block at 128 bits, so no published vector set covers the wider blocks and libmcrypt is the only thing left to check against.
$ciphertext = mcrypt_encrypt(MCRYPT_RIJNDAEL_128, $key, $plaintext, MCRYPT_MODE_CBC, $iv);const {default: {algorithm, mode}, padding} = require('cryptian');
const rijndael = new algorithm.Rijndael128();
rijndael.setKey(key);
const cipher = new mode.cbc.Cipher(rijndael, iv);
const padder = new padding.Null(cipher.getBlockSize());
const ciphertext = cipher.transform(padder.pad(plaintext));$ciphertext = mcrypt_encrypt(MCRYPT_RIJNDAEL_256, $key, $plaintext, MCRYPT_MODE_CBC, $iv);const rijndael = new algorithm.Rijndael256();
rijndael.setKey(key);
// the block is 32 bytes here, so the iv must be 32 bytes too
const cipher = new mode.cbc.Cipher(rijndael, iv);
const padder = new padding.Null(cipher.getBlockSize());
const ciphertext = cipher.transform(padder.pad(plaintext));The IV width follows the block, not the key. A 32 byte block needs a 32 byte IV. Passing a 16 byte IV now throws rather than corrupting memory.
$ciphertext = mcrypt_encrypt(MCRYPT_BLOWFISH_COMPAT, $key, $plaintext, MCRYPT_MODE_CBC, $iv);const blowfish = new algorithm.Blowfish();
blowfish.setKey(key);
blowfish.setEndianCompat(true); // this is what COMPAT means
const cipher = new mode.cbc.Cipher(blowfish, iv);MCRYPT_MODE_STREAM has no mode object. Use the algorithm directly, and do not
pad:
$ciphertext = mcrypt_encrypt(MCRYPT_ARCFOUR, $key, $plaintext, MCRYPT_MODE_STREAM);const arcfour = new algorithm.Arcfour();
arcfour.setKey(key);
const ciphertext = arcfour.encrypt(plaintext);For anything that does not fit comfortably in memory, use the stream helpers. They handle block alignment and remove padding on the final chunk:
const fs = require('fs');
const {default: {algorithm, mode}, padding, createDecryptStream} = require('cryptian');
const rijndael = new algorithm.Rijndael256();
rijndael.setKey(key);
fs.createReadStream('legacy.enc')
.pipe(createDecryptStream(new mode.cbc.Decipher(rijndael, iv), padding.Null))
.pipe(fs.createWriteStream('legacy.out'));- Is the constant
MCRYPT_RIJNDAEL_192or_256? The number is the block size. It is not AES and the IV must match that width. - Did the data come from
mcrypt_encrypt()? Usepadding.Null. - Is the mode
cfborofb? Those are the 8 bit variants. The full block ones arencfbandnofb. - Is the constant
MCRYPT_BLOWFISH_COMPAT? CallsetEndianCompat(true). - Was the key shorter than the cipher wanted? It was zero-padded, and still is.
These algorithms are here to read existing data. Most should not be chosen for anything new.
- DES, single DES in particular, and RC2, RC4, Enigma and WAKE are broken or far too weak for current use.
- ECB reveals structure in the plaintext because identical blocks encrypt identically. Avoid it outside compatibility work.
- None of these modes authenticate. Ciphertext can be altered undetected. If
you need integrity, use an AEAD construction such as AES-GCM from node's
built-in
crypto. - The padding routines are not constant time. Do not use unpad failures to gate authentication decisions, or you may build a padding oracle.
The safe pattern for a migration is to decrypt with cryptian once and re-encrypt with an authenticated modern cipher, rather than keeping the legacy scheme alive.