skip to Main Content

I have a simple microsite where a user records a video of themselves using the built-in JS MediaRecorder (https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder) and it gets encrypted using openssl_encrypt.

When they want to share/view that video, it’s decrypted using openssl_decrypt.

The problem is, once it comes out the decrypt function the quality has reduced to the point you see artifacts. WebM videos are still watchable, but MP4 videos (captured from iPhone) are unwatchable – the video has 1FPS… What could be causing this dramatic drop in quality?

The solution was working perfectly until we implemented this encryption.

We pass the recorded file into it directly.

encryptFile($_FILES["file"]['tmp_name'], $dir . $filename, 'secret-key');
decryptFile("videos/" . $id . "." . $mime, $decrypted_video, 'secret-key');

encryptFile();

/**
 * @param  $source  Path of the unencrypted file
 * @param  $dest  Path of the encrypted file to created
 * @param  $key  Encryption key
 */
function encryptFile($source, $dest, $key)
{

        $cipher = 'aes-256-cbc';
        $ivLenght = openssl_cipher_iv_length($cipher);
        $iv = openssl_random_pseudo_bytes($ivLenght);
    
        $fpSource = fopen($source, 'rb');
        $fpDest = fopen($dest, 'w');
    
        fwrite($fpDest, $iv);
    
        while (!feof($fpSource)) {
            $plaintext = fread($fpSource, $ivLenght * FILE_ENCRYPTION_BLOCKS);
            $ciphertext = openssl_encrypt($plaintext, $cipher, $key, OPENSSL_RAW_DATA, $iv);
            $iv = substr($ciphertext, 0, $ivLenght);
    
            fwrite($fpDest, $ciphertext);
        }
    
        fclose($fpSource);
        fclose($fpDest);
}

decryptFile();

/**
 * @param  $source  Path of the encrypted file
 * @param  $dest  Path of the decrypted file
 * @param  $key  Encryption key
 */
function decryptFile($source, $dest, $key)
{
    $cipher = 'aes-256-cbc';
    $ivLenght = openssl_cipher_iv_length($cipher);

    $fpSource = fopen($source, 'rb');
    $fpDest = fopen($dest, 'w');

    $iv = fread($fpSource, $ivLenght);

    while (!feof($fpSource)) {
        $ciphertext = fread($fpSource, $ivLenght * (FILE_ENCRYPTION_BLOCKS + 1));
        $plaintext = openssl_decrypt($ciphertext, $cipher, $key, OPENSSL_RAW_DATA, $iv);
        $iv = substr($plaintext, 0, $ivLenght);

        fwrite($fpDest, $plaintext);
    }

    fclose($fpSource);
    fclose($fpDest);
}

2

Answers


  1. Decryption is not consistent with encryption and determines a wrong IV.

    The wrong IVs cause the first block in each decrypted plaintext chunk to be corrupted. The larger the chunksize i.e. the fewer chunks the fewer errors, which explains the improvement when increasing FILE_ENCRYPTION_BLOCKS.

    For encryption and decryption to be consistent, it must be in decryptFile():

    $iv = substr($ciphertext, 0, $ivLenght);
    

    The code would be more robust if the logic mimicked CBC mode so that decryption were possible without knowing the chunk size. To achieve this

    • only the last plaintext chunk must be padded (currently: all chunks are padded),
    • the last block of the previous ciphertext chunk must be used as IV ( currently: the first block is used).

    An alternative to avoid padding at all is to use a stream cipher mode like CTR.

    Login or Signup to reply.
  2. OK, I got nerd-sniped by this question and dove in pretty deep.

    The summary is that:

    1. If you want your code to encrypt/decrypt entirely within the scope of your own code, and with a fixed FILE_ENCRYPTION_BLOCKS value, then the IV fix in @Topaco’s answer will likely suffice. Otherwise you will need to apply the rest of the suggested fixes.
    2. Changing FILE_ENCRYPTION_BLOCKS in your existing code likely just moves the compatibility breakage further down the line, possibly past the end of the file, at which point there’s not really any point in streaming encryption code anyway.
    3. If you want your code to produce output compatible with OpenSSL and other libs then… here be dragons

    The simplest answer would be to use an existing library that already does this, and does it well. Namely: jeskew/php-encrypted-streams, which I heavily referrenced to in making my own code work after I hit a wall with it.

    The more complex answer is:

    • The block size is not necessarily the same as the IV size, and it’s kind of a kludge to detect.
    • The IV for the next block is the last $iv_size bytes of the previous block.
    • All but the final block need `OPENSSL_ZERO_PADDING.
    //define('DEBUG', true);
    function debug(...$args) {
        if( defined('DEBUG') && DEBUG === true ) {
            printf(...$args);
        }
    }
    
    if( ! function_exists('openssl_cipher_block_length') ) {
        function openssl_cipher_block_length($cipher) {
            return strlen(openssl_encrypt(
                "x00", $cipher, "", OPENSSL_RAW_DATA, str_repeat("x00", openssl_cipher_iv_length($cipher))
            ));
        }
    }
    
    function openssl_encrypt_stream($stream_in, $stream_out, $key, $iv, $cipher, $block_multiplier=256) {
        $cipher_block_length = openssl_cipher_block_length($cipher);
        $buffer_size = $cipher_block_length * $block_multiplier;
        $iv_size = openssl_cipher_iv_length($cipher);
    
        debug("Cipher: %s, Block Size: %d, IV Size: %d, Block Multiplier: %dn", $cipher, $cipher_block_length, $iv_size, $block_multiplier);
    
        while( ! feof($stream_in) ) {
            $buffer = fread($stream_in, $buffer_size);
            debug("Plaintext Buffer Length: %d, Content: %sn", strlen($buffer), bin2hex($buffer));
    
            $options = OPENSSL_RAW_DATA;
            if( feof($stream_in) ) {
                debug("Final block.n");
            } else {
                $options |= OPENSSL_ZERO_PADDING;
            }
            $e_buffer = openssl_encrypt($buffer, $cipher, $key, $options, $iv);
    
            debug("IV: %s, Ciphertext: %sn", bin2hex($iv), bin2hex($e_buffer));
    
            if( $e_buffer === false ) {
                throw new Exception(openssl_error_string());
            }
    
            $iv = substr($e_buffer, -1 * $cipher_block_length);
            fwrite($stream_out, $e_buffer);
            debug(PHP_EOL);
        }
    }
    
    function openssl_decrypt_stream($stream_in, $stream_out, $key, $iv, $cipher, $block_multiplier=256) {
        $cipher_block_length = openssl_cipher_block_length($cipher);
        $buffer_size = $cipher_block_length * $block_multiplier;
        $iv_size = openssl_cipher_iv_length($cipher);
    
        debug("Cipher: %s, Block Size: %d, IV Size: %d, Block Multiplier: %dn", $cipher, $cipher_block_length, $iv_size, $block_multiplier);
    
        $next_buffer = fread($stream_in, $buffer_size);
        do {
            $buffer = $next_buffer;
            $next_buffer = fread($stream_in, $buffer_size);
            debug("Ciphertext Buffer Length: %d, Content: %sn", strlen($buffer), bin2hex($buffer));
    
            $options = OPENSSL_RAW_DATA;
            if( feof($stream_in) && $next_buffer === '' ) {
                debug("Final block.n");
            } else {
                $options |= OPENSSL_ZERO_PADDING;
            }
            $p_buffer = openssl_decrypt($buffer, $cipher, $key, $options, $iv);
    
            debug("IV: %s, Plaintext: %sn", bin2hex($iv), bin2hex($p_buffer));
    
            if( $p_buffer === false ) {
                throw new Exception(openssl_error_string());
            }
    
            $iv = substr($buffer, -1 * $cipher_block_length);
            fwrite($stream_out, $p_buffer);
            debug(PHP_EOL);
        } while( !( feof($stream_in) && $next_buffer === '') );
    }
    

    Testing code:

    // encrypt/decrypt normally as a control
    function e_control($file, $key, $iv, $cipher) {
        $begin = hrtime(true);
        $enc = openssl_encrypt(file_get_contents($file), $cipher, $key, OPENSSL_RAW_DATA, $iv);
        $dur = hrtime(true) - $begin;
        return [ $enc, $dur ];
    }
    
    function d_control($enc, $key, $iv, $cipher) {
        $begin = hrtime(true);
        $plain = openssl_decrypt($enc, $cipher, $key, OPENSSL_RAW_DATA, $iv);
        $dur = hrtime(true) - $begin;
        return [ $plain, $dur ];
    }
    
    // encrypt test
    function e_test($file, $key, $iv, $cipher, $block_multiplier=256) {
        $stream_out = fopen('php://memory', 'rwb');
    
        $begin = hrtime(true);
        $stream_in  = fopen($file, 'rb'); // fairness with control
        openssl_encrypt_stream($stream_in, $stream_out, $key, $iv, $cipher, $block_multiplier);
        $dur = hrtime(true) - $begin;
    
        fclose($stream_in);
        rewind($stream_out);
        return [ stream_get_contents($stream_out), $dur ];
    }
    
    // decrypt test
    function d_test($enc, $key, $iv, $cipher, $block_multiplier=256) {
        $stream_in  = fopen('php://memory', 'rwb');
        $stream_out = fopen('php://memory', 'rwb');
        fwrite($stream_in, $enc);
        rewind($stream_in);
    
        $begin = hrtime(true);
        openssl_decrypt_stream($stream_in, $stream_out, $key, $iv, $cipher, $block_multiplier);
        $dur = hrtime(true) - $begin;
    
        fclose($stream_in);
        rewind($stream_out);
        return [ stream_get_contents($stream_out), $dur ];
    }
    
    // dd if=/dev/random of=./test.bin bs=1024 count=10240
    $file   = 'test.bin';
    $cipher = 'aes-256-cbc';
    $key    = str_repeat("x00", 32);
    $iv     = str_repeat("x00", openssl_cipher_iv_length($cipher));
    $mult   = 256;
    
    // warm the FS cache for fairness
    file_get_contents($file);
    
    list($c, $c_dur) = e_control( $file, $key, $iv, $cipher);
    list($e, $e_dur) = e_test(    $file, $key, $iv, $cipher, $mult);
    list($x, $x_dur) = d_control( $c,    $key, $iv, $cipher);
    list($d, $d_dur) = d_test(    $c,    $key, $iv, $cipher, $mult);
    
    printf("Control - Hash: %s, Duration: %0.3f msn", md5($c), $c_dur / 1000000);
    printf("Encrypt - Hash: %s, Duration: %0.3f msn", md5($e), $e_dur / 1000000);
    printf("Control - Hash: %s, Duration: %0.3f msn", md5($x), $x_dur / 1000000);
    printf("Decrypt - Hash: %s, Duration: %0.3f msn", md5($d), $d_dur / 1000000);
    

    Example Output:

    Control - Hash: 5f5db49554de8fa6a7195c4d0cbc0ad8, Duration: 30.788 ms
    Encrypt - Hash: 5f5db49554de8fa6a7195c4d0cbc0ad8, Duration: 75.244 ms
    Control - Hash: 8eee2cf0ee0444cafa9280a89821f1ff, Duration: 9.054 ms
    Decrypt - Hash: 8eee2cf0ee0444cafa9280a89821f1ff, Duration: 61.600 ms
    

    It’s worth noting that while $cipher in this code seems configurable, this methodology will only work with ciphers in CBC mode, and probably only AES at that. The jeskew/php-encrypted-streams lib linked above implements a wider range of cipher modes, but still only AES.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search