4
\$\begingroup\$

I spent quite some time and wrote a single class for the purpose of encrypting/decrypting plaintext bytes with a passphrase. The goal is to not have any further dependencies apart from java.* and javax.*.

It shall provide confidentiality and integrity for encrypted plaintext in the order of kilobytes and should be indistinguishable under a chosen plaintext attack (IND-CPA) (apart from the leading constant byte).

package cryptor;

import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.KeySpec;

import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;

/**
 * @author trichner
 * @created 19.02.18
 */
public class AesGcmCryptor {

    // https://crypto.stackexchange.com/questions/26783/ciphertext-and-tag-size-and-iv-transmission-with-aes-in-gcm-mode
    private static final byte[] VERSION_BYTE = new byte[] { (byte) 0x01 };
    private static final int AES_KEY_BITS_LENGTH = 128;
    private static final int GCM_IV_BYTES_LENGTH = 12;
    private static final int GCM_TAG_BYTES_LENGTH = 16;

    private static final int PBKDF2_ITERATIONS = 16384;

    private static final byte[] PBKDF2_SALT = hexStringToByteArray("4d3fe0d71d2abd2828e7a3196ea450d4");

    /**
     * Decrypts an AES-GCM encrypted ciphertext and is
     * the reverse operation of {@link AesGcmCryptor#encrypt(char[], byte[])}
     *
     * @param password   passphrase for decryption
     * @param ciphertext encrypted bytes
     *
     * @return plaintext bytes
     *
     * @throws NoSuchPaddingException
     * @throws NoSuchAlgorithmException
     * @throws NoSuchProviderException
     * @throws InvalidKeySpecException
     * @throws InvalidAlgorithmParameterException
     * @throws InvalidKeyException
     * @throws BadPaddingException
     * @throws IllegalBlockSizeException
     * @throws IllegalArgumentException           if the length or format of the ciphertext is bad
     */
    public byte[] decrypt(char[] password, byte[] ciphertext)
            throws NoSuchPaddingException, NoSuchAlgorithmException, NoSuchProviderException, InvalidKeySpecException,
            InvalidAlgorithmParameterException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException,
            BadVersionException {

        // input validation
        if (ciphertext == null) {
            throw new IllegalArgumentException("Ciphertext cannot be null.");
        }

        if (ciphertext.length <= VERSION_BYTE.length + GCM_IV_BYTES_LENGTH + GCM_TAG_BYTES_LENGTH) {
            throw new IllegalArgumentException("Ciphertext too short.");
        }

        // The version byte must have a 0 MSB in this version,
        // this allows us to expand the header to multiple bytes if ever necessary.
        // The MSB indicates if the current octet is the last octet of the header.
        if ((ciphertext[0] & (1 << 7)) != 0) {
            throw new BadVersionException();
        }

        // The version must match.
        for (int i = 0; i < VERSION_BYTE.length; i++) {
            if (VERSION_BYTE[i] != ciphertext[i]) {
                throw new BadVersionException();
            }
        }

        // input seems legit, lets decrypt and check integrity

        // derive key from password
        SecretKey key = deriveAesKey(password, PBKDF2_SALT, AES_KEY_BITS_LENGTH);

        // init cipher
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding", "SunJCE");
        GCMParameterSpec params = new GCMParameterSpec(GCM_TAG_BYTES_LENGTH * 8,
                ciphertext,
                VERSION_BYTE.length,
                GCM_IV_BYTES_LENGTH
        );
        cipher.init(Cipher.DECRYPT_MODE, key, params);

        // add version and IV to MAC
        cipher.updateAAD(ciphertext, 0, GCM_IV_BYTES_LENGTH + VERSION_BYTE.length);

        // decipher and check MAC
        return cipher.doFinal(ciphertext, 13, ciphertext.length - GCM_IV_BYTES_LENGTH - VERSION_BYTE.length);
    }

    /**
     * Encrypts a plaintext with a password.
     *
     * The encryption provides the following security properties:
     * Confidentiality + Integrity
     *
     * This is achieved my using the AES-GCM AEAD blockmode with a randomized IV.
     *
     * The tag is calculated over the version byte, the IV as well as the ciphertext.
     *
     * Finally the encrypted bytes have the following structure:
     * <pre>
     *          +-------------------------------------------------------------------+
     *          |         |               |                             |           |
     *          | version | IV bytes      | ciphertext bytes            |    tag    |
     *          |         |               |                             |           |
     *          +-------------------------------------------------------------------+
     * Length:     1B        12B            len(plaintext) bytes            16B
     * </pre>
     * Note: There is no padding required for AES-GCM, but this also implies that
     * the exact plaintext length is revealed.
     *
     * @param password  password to use for encryption
     * @param plaintext plaintext to encrypt
     *
     * @throws NoSuchAlgorithmException
     * @throws NoSuchProviderException
     * @throws NoSuchPaddingException
     * @throws InvalidAlgorithmParameterException
     * @throws InvalidKeyException
     * @throws BadPaddingException
     * @throws IllegalBlockSizeException
     * @throws InvalidKeySpecException
     */
    public byte[] encrypt(char[] password, byte[] plaintext)
            throws NoSuchAlgorithmException, NoSuchProviderException, NoSuchPaddingException,
            InvalidAlgorithmParameterException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException,
            InvalidKeySpecException {

        // initialise random and generate IV (initialisation vector)
        SecretKey key = deriveAesKey(password, PBKDF2_SALT, AES_KEY_BITS_LENGTH);
        final byte[] iv = new byte[GCM_IV_BYTES_LENGTH];
        SecureRandom random = SecureRandom.getInstanceStrong();
        random.nextBytes(iv);

        // encrypt
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding", "SunJCE");
        GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_BYTES_LENGTH * 8, iv);
        cipher.init(Cipher.ENCRYPT_MODE, key, spec);

        // add IV to MAC
        cipher.updateAAD(VERSION_BYTE);
        cipher.updateAAD(iv);

        // encrypt and MAC plaintext
        byte[] ciphertext = cipher.doFinal(plaintext);

        // prepend VERSION and IV to ciphertext
        byte[] encrypted = new byte[1 + GCM_IV_BYTES_LENGTH + ciphertext.length];
        int pos = 0;
        System.arraycopy(VERSION_BYTE, 0, encrypted, 0, VERSION_BYTE.length);
        pos += VERSION_BYTE.length;
        System.arraycopy(iv, 0, encrypted, pos, iv.length);
        pos += iv.length;
        System.arraycopy(ciphertext, 0, encrypted, pos, ciphertext.length);

        return encrypted;
    }

    /**
     * We derive a fixed length AES key with uniform entropy from a provided
     * passphrase. This is done with PBKDF2/HMAC256 with a fixed count
     * of iterations and a provided salt.
     *
     * @param password passphrase to derive key from
     * @param salt     salt for PBKDF2 if possible use a per-key salt, alternatively
     *                 a random constant salt is better than no salt.
     * @param keyLen   number of key bits to output
     *
     * @return a SecretKey for AES derived from a passphrase
     *
     * @throws NoSuchAlgorithmException
     * @throws InvalidKeySpecException
     */
    private SecretKey deriveAesKey(char[] password, byte[] salt, int keyLen)
            throws NoSuchAlgorithmException, InvalidKeySpecException {

        if (password == null || salt == null || keyLen <= 0) {
            throw new IllegalArgumentException();
        }
        SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
        KeySpec spec = new PBEKeySpec(password, salt, PBKDF2_ITERATIONS, keyLen);
        SecretKey pbeKey = factory.generateSecret(spec);

        return new SecretKeySpec(pbeKey.getEncoded(), "AES");
    }

    /**
     * Helper to convert hex strings to bytes.
     *
     * This is neither null save nor does it go well with invalid hex strings.
     * Therefore it is important that this method is not used with user provided strings.
     */
    private static byte[] hexStringToByteArray(String s) {

        int len = s.length();

        byte[] data = new byte[len / 2];
        for (int i = 0; i < len - 1; i++) {
            data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) + Character.digit(s.charAt(i + 1), 16));
        }
        return data;
    }
}

Why not use a library?

  • Bouncy Castle is a huge dependency for simple encryption
  • Jasypt might have an easy API, but I couldn't even find out if it provides integrity or indistinguishability under chosen plaintext attack

Why not copy something from Stackoverflow?

  • All encryption related code I found on Stackoverlow provided either no integrity at all or was broken in some way or another, e.g. not using a KDF for the password, not checking the integrity of the IV, ...

EDIT

I fixed some minor issues, you may find the entire source here: https://github.com/trichner/tcrypt

\$\endgroup\$

1 Answer 1

3
\$\begingroup\$
        if (ciphertext.length <= VERSION_BYTE.length + GCM_IV_BYTES_LENGTH + GCM_TAG_BYTES_LENGTH) {
            throw new IllegalArgumentException("Ciphertext too short.");
        }

        // The version byte must have a 0 MSB in this version,
        // this allows us to expand the header to multiple bytes if ever necessary.
        // The MSB indicates if the current octet is the last octet of the header.
        if ((ciphertext[0] & (1 << 7)) != 0) {
            throw new BadVersionException();
        }

        // The version must match.
        for (int i = 0; i < VERSION_BYTE.length; i++) {
            if (VERSION_BYTE[i] != ciphertext[i]) {
                throw new BadVersionException();
            }
        }

This doesn't seem to me to hang together. If the code is supposed to be robust against the version length changing, why isn't the sequence (a) check ciphertext.length is long enough for the version; (b) check the version; (c) check that the ciphertext without the version is long enough for whatever extra constraints the current version imposes in terms of block size, tag length, etc?

And given that the final check forces VERSION_BYTE to be a prefix of ciphertext, why is the sanity check on the multi-byte flags a static check on VERSION_BYTE instead of an instance check on ciphertext?


        // decipher and check MAC
        return cipher.doFinal(ciphertext, 13, ciphertext.length - GCM_IV_BYTES_LENGTH - VERSION_BYTE.length);
        // encrypt and MAC plaintext
        byte[] ciphertext = cipher.doFinal(plaintext);

The stated goal is to handle "encrypted plaintext in the order of kilobytes". Depending on how tight that constraint is, you may have a problem here. I have recently learnt the hard way that some providers have (not obviously documented) limits on the size of ciphertext or plaintext that they will handle correctly in doFinal.


     * The tag is calculated over the version byte, the IV as well as the ciphertext.
        // add IV to MAC
        cipher.updateAAD(VERSION_BYTE);
        cipher.updateAAD(iv);

Why? I can just about understand including the version, although changing that is likely to break things. But the IV is implicitly verified by the MAC already without including it in the AAD.

I'm not saying that you're wrong to include it, but it is surprising and therefore the reason should be documented, even if it's only with a link to a crypto.stackexchange.com answer which makes a case for including it.


        byte[] ciphertext = cipher.doFinal(plaintext);

        // prepend VERSION and IV to ciphertext
        byte[] encrypted = new byte[1 + GCM_IV_BYTES_LENGTH + ciphertext.length];
        ...
        System.arraycopy(ciphertext, 0, encrypted, pos, ciphertext.length);

I'm pretty sure there's an overload of cipher.doFinal which would save that big System.arraycopy.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.