4

I was trying to implement an encrypt/decrypt program using classes under javax.crypto and file streams for input/output. To limit the memory usage, I run with -Xmx256m parameter.

It works fine with encryption and decryption with smaller files. But when decrypt a huge file (1G in size), there is an out of memory exception:

java.lang.OutOfMemoryError: Java heap space
    at java.util.Arrays.copyOf(Arrays.java:3236)
    at java.io.ByteArrayOutputStream.grow(ByteArrayOutputStream.java:118)
    at java.io.ByteArrayOutputStream.ensureCapacity(ByteArrayOutputStream.java:93)
    at java.io.ByteArrayOutputStream.write(ByteArrayOutputStream.java:153)
    at com.sun.crypto.provider.GaloisCounterMode.decrypt(GaloisCounterMode.java:505)
    at com.sun.crypto.provider.CipherCore.update(CipherCore.java:782)
    at com.sun.crypto.provider.CipherCore.update(CipherCore.java:667)
    at com.sun.crypto.provider.AESCipher.engineUpdate(AESCipher.java:380)
    at javax.crypto.Cipher.update(Cipher.java:1831)
    at javax.crypto.CipherOutputStream.write(CipherOutputStream.java:166)

Here is the decrypt code:

private final int _readSize = 0x10000;//64k

...

GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(gcmTagSize, iv);
Key keySpec = new SecretKeySpec(key, keyParts[0]);
Cipher decCipher = Cipher.getInstance("AES/GCM/PKCS5Padding");

decCipher.init(Cipher.DECRYPT_MODE, keySpec, gcmParameterSpec);

try (InputStream fileInStream = Files.newInputStream(inputEncryptedFile);
    OutputStream fileOutStream = Files.newOutputStream(outputDecryptedFile)) {
    try (CipherOutputStream cipherOutputStream = new CipherOutputStream(fileOutStream, decCipher)) {
        long count = 0L;
        byte[] buffer = new byte[_readSize];

        int n;
        for (; (n = fileInStream.read(buffer)) != -1; count += (long) n) {
            cipherOutputStream.write(buffer, 0, n);
        }
    }
}

The key parameters like gcmTagSize and iv are read from a key file, and it works fine with smaller files, like some one around size of 50M.

As I understand, every time there are only 64k data passed to decipher, why it runs out of heap memory? How can I avoid this?

Edit:

Actually I have tried with 4k as buffer size, failed with same exception.

Edit 2:

With more testing, the max file size it can handle is around 1/4 of the heap size. Like, if you set -Xmx256m, files bigger than 64M will fail to decrypt.

trincot
  • 317,000
  • 35
  • 244
  • 286
Kai
  • 43
  • 6

2 Answers2

5

This appears to be an issue with the implementation of the GCM mode. I'm not sure that you can work-around it.

If you look at your stack trace:

java.lang.OutOfMemoryError: Java heap space
    at java.util.Arrays.copyOf(Arrays.java:3236)
    at java.io.ByteArrayOutputStream.grow(ByteArrayOutputStream.java:118)
    at java.io.ByteArrayOutputStream.ensureCapacity(ByteArrayOutputStream.java:93)
    at java.io.ByteArrayOutputStream.write(ByteArrayOutputStream.java:153)
    at com.sun.crypto.provider.GaloisCounterMode.decrypt(GaloisCounterMode.java:505)

The out-of-memory error is happening when writing to a ByteArrayOutputStream from within GaloisCounterMode. You use a FileOutputStream, so either you're not showing the right code or this ByteArrayStream is used internally.

If you look at the source for GaloisCounterMode you'll see that it defines an internal ByteArrayOutputStream (it actually defines two, but I think this is the one that's the problem):

    // buffer for storing input in decryption, not used for encryption
    private ByteArrayOutputStream ibuffer = null;

Then, later down, it writes bytes to this stream. Note the code comment.

    int decrypt(byte[] in, int inOfs, int len, byte[] out, int outOfs) {
        processAAD();

        if (len > 0) {
            // store internally until decryptFinal is called because
            // spec mentioned that only return recovered data after tag
            // is successfully verified
            ibuffer.write(in, inOfs, len);
        }
        return 0;
    }

That buffer isn't reset until decryptFinal().


Edit: looking at this CSx answer it looks like GCM needs to buffer the entire stream. That would make it a very bad choice if you have large files and not enough memory.

I think your best solution is to switch to CBC mode.

Parsifal
  • 3,928
  • 5
  • 9
3

The bad news are: IMHO the error is caused by an bad implementation of AES GCM-mode in native Java. Even if you could get it to work you will find that the decryption of a large file (1 GB or so) will take a lot of time (maybe hours ?). But there are good news: you could/should use BouncyCastle as service provider for your decryption task - that way the decryption will work and it's much faster.

The following full example will create a sample file of 1 gb size, encrypts it with BouncyCastle and later decrypts it. In the end there is a file compare to show that plain and decrypted file contents are equal and the files will be deleted. You need temporary a total of more than 3 GB free space on your device to run this example.

Using a buffer of 64 KB I'm running this example with this data:

Milliseconds for Encryption: 14295 | Decryption: 16249

A buffer of 1 KB is a little bit slower on encryption side but much slower on decryption task:

Milliseconds for Encryption: 15250 | Decryption: 21952

A last word regarding your cipher - "AES/GCM/PKCS5Padding" is not existing and "available" in some implementations but the real used algorithm is "AES/GCM/NoPadding" (see Can PKCS5Padding be in AES/GCM mode? for more details).

import org.bouncycastle.jce.provider.BouncyCastleProvider;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.io.*;
import java.nio.file.Files;
import java.security.*;
import java.util.Arrays;

public class GcmTestBouncyCastle {
    public static void main(String[] args) throws IOException, NoSuchPaddingException, InvalidAlgorithmParameterException,
            NoSuchAlgorithmException, IllegalBlockSizeException, BadPaddingException, NoSuchProviderException, InvalidKeyException {
        System.out.println("Encryption & Decryption with BouncyCastle AES-GCM-Mode");
        System.out.println("https://stackoverflow.com/questions/61792534/out-of-memory-exception-when-decrypt-large-file-using-cipher");
        // you need bouncy castle, get version 1.65 here:
        // https://mvnrepository.com/artifact/org.bouncycastle/bcprov-jdk15on/1.65
        Security.addProvider(new BouncyCastleProvider());
        // setup files
        // filenames
        String filenamePlain = "plain.dat";
        String filenameEncrypt = "encrypt.dat";
        String filenameDecrypt = "decrypt.dat";
        // generate a testfile of 1024 byte | 1 gb
        //createFileWithDefinedLength(filenamePlain, 1024);
        createFileWithDefinedLength(filenamePlain, 1024 * 1024 * 1024); // 1 gb
        // time measurement
        long startMilli = 0;
        long encryptionMilli = 0;
        long decryptionMilli = 0;
        // generate nonce/iv
        int GCM_NONCE_LENGTH = 12; // for a nonce of 96 bit length
        int GCM_TAG_LENGTH = 16;
        int GCM_KEY_LENGTH = 32; // 32 = 256 bit keylength, 16 = 128 bit keylength
        SecureRandom r = new SecureRandom();
        byte[] nonce = new byte[GCM_NONCE_LENGTH];
        r.nextBytes(nonce);
        // key should be generated as random byte[]
        byte[] key = new byte[GCM_KEY_LENGTH];
        r.nextBytes(key);
        // encrypt file
        startMilli = System.currentTimeMillis();
        encryptWithGcmBc(filenamePlain, filenameEncrypt, key, nonce, GCM_TAG_LENGTH);
        encryptionMilli = System.currentTimeMillis() - startMilli;
        startMilli = System.currentTimeMillis();
        decryptWithGcmBc(filenameEncrypt, filenameDecrypt, key, nonce, GCM_TAG_LENGTH);
        decryptionMilli = System.currentTimeMillis() - startMilli;
        // check that plain and decrypted files are equal
        System.out.println("SHA256-file compare " + filenamePlain + " | " + filenameDecrypt + " : "
                + Arrays.equals(sha256File(filenamePlain), sha256File(filenameDecrypt)));
        System.out.println("Milliseconds for Encryption: " + encryptionMilli + " | Decryption: " + decryptionMilli);
        // clean up with files
        Files.deleteIfExists(new File(filenamePlain).toPath());
        Files.deleteIfExists(new File(filenameEncrypt).toPath());
        Files.deleteIfExists(new File(filenameDecrypt).toPath());
    }

    public static void encryptWithGcmBc(String filenamePlain, String filenameEnc, byte[] key, byte[] nonce, int gcm_tag_length)
            throws IOException, NoSuchAlgorithmException, NoSuchPaddingException, InvalidKeyException,
            InvalidAlgorithmParameterException, IllegalBlockSizeException, BadPaddingException, NoSuchProviderException {
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding", "BC");
        SecretKeySpec keySpec = new SecretKeySpec(key, "AES");
        GCMParameterSpec gcmSpec = new GCMParameterSpec(gcm_tag_length * 8, nonce);
        cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec);

        try (FileInputStream fis = new FileInputStream(filenamePlain);
             BufferedInputStream in = new BufferedInputStream(fis);
             FileOutputStream out = new FileOutputStream(filenameEnc);
             BufferedOutputStream bos = new BufferedOutputStream(out)) {
            //byte[] ibuf = new byte[1024];
            byte[] ibuf = new byte[0x10000]; // = 65536
            int len;
            while ((len = in.read(ibuf)) != -1) {
                byte[] obuf = cipher.update(ibuf, 0, len);
                if (obuf != null)
                    bos.write(obuf);
            }
            byte[] obuf = cipher.doFinal();
            if (obuf != null)
                bos.write(obuf);
        }
    }

    public static void decryptWithGcmBc(String filenameEnc, String filenameDec, byte[] key, byte[] nonce, int gcm_tag_length)
            throws IOException, NoSuchAlgorithmException, NoSuchPaddingException, InvalidKeyException,
            InvalidAlgorithmParameterException, IllegalBlockSizeException, BadPaddingException, NoSuchProviderException {
        try (FileInputStream in = new FileInputStream(filenameEnc);
             FileOutputStream out = new FileOutputStream(filenameDec)) {
            //byte[] ibuf = new byte[1024];
            byte[] ibuf = new byte[0x10000]; // = 65536
            int len;
            Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding", "BC");
            SecretKeySpec keySpec = new SecretKeySpec(key, "AES");
            GCMParameterSpec gcmSpec = new GCMParameterSpec(gcm_tag_length * 8, nonce);
            cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec);
            while ((len = in.read(ibuf)) != -1) {
                byte[] obuf = cipher.update(ibuf, 0, len);
                if (obuf != null)
                    out.write(obuf);
            }
            byte[] obuf = cipher.doFinal();
            if (obuf != null)
                out.write(obuf);
        }
    }

    // just for creating a large file within seconds
    private static void createFileWithDefinedLength(String filenameString, long sizeLong) throws IOException {
        RandomAccessFile raf = new RandomAccessFile(filenameString, "rw");
        try {
            raf.setLength(sizeLong);
        } finally {
            raf.close();
        }
    }

    // just for file comparing
    public static byte[] sha256File(String filenameString) throws IOException, NoSuchAlgorithmException {
        byte[] buffer = new byte[8192];
        int count;
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        BufferedInputStream bis = new BufferedInputStream(new FileInputStream(filenameString));
        while ((count = bis.read(buffer)) > 0) {
            md.update(buffer, 0, count);
        }
        bis.close();
        return md.digest();
    }
}
Michael Fehr
  • 5,827
  • 2
  • 19
  • 40