org.nuxeo.common.codec.Crypto.java Source code

Java tutorial

Introduction

Here is the source code for org.nuxeo.common.codec.Crypto.java

Source

/*
 * (C) Copyright 2015 Nuxeo SA (http://nuxeo.com/) and others.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 * Contributors:
 *     jcarsique
 */
package org.nuxeo.common.codec;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.security.GeneralSecurityException;
import java.security.InvalidKeyException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.Security;
import java.security.KeyStore.PasswordProtection;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

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

import org.apache.commons.codec.binary.Base64;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

/**
 * Supported algorithms (name, keysize):
 * <ul>
 * <li>AES/ECB/PKCS5Padding (128)</li>
 * <li>DES/ECB/PKCS5Padding (64)</li>
 * <ul/>
 *
 * @since 7.4
 */
public class Crypto {

    protected static final Pattern CRYPTO_PATTERN = Pattern.compile("\\{\\$(?<algo>.*)\\$(?<value>.+)\\}");

    private static final Log log = LogFactory.getLog(Crypto.class);

    public static final String AES = "AES";

    public static final String AES_ECB_PKCS5PADDING = "AES/ECB/PKCS5Padding";

    public static final String DES = "DES";

    public static final String DES_ECB_PKCS5PADDING = "DES/ECB/PKCS5Padding";

    public static final String[] IMPLEMENTED_ALGOS = { AES, DES, AES_ECB_PKCS5PADDING, DES_ECB_PKCS5PADDING };

    public static final String DEFAULT_ALGO = AES_ECB_PKCS5PADDING;

    private static final String SHA1 = "SHA-1";

    private final byte[] secretKey;

    private final Map<String, SecretKey> secretKeys = new HashMap<>();

    private boolean initialized = true;

    private final byte[] digest;

    /**
     * @param secretKey
     */
    public Crypto(byte[] secretKey) {
        this.secretKey = secretKey;
        digest = getSHA1DigestOrEmpty(secretKey);
        if (digest.length == 0) {
            clear();
        }
    }

    /**
     * Initialize cryptography with a map of {@link SecretKey}.
     *
     * @param secretKeys Map of {@code SecretKey} per algorithm
     */
    public Crypto(Map<String, SecretKey> secretKeys) {
        this(secretKeys, Crypto.class.getName().toCharArray());
    }

    /**
     * Initialize cryptography with a map of {@link SecretKey}.
     *
     * @param digest Digest for later use by {@link #verifyKey(byte[])}
     * @param secretKeys Map of {@code SecretKey} per algorithm
     */
    public Crypto(Map<String, SecretKey> secretKeys, char[] digest) {
        secretKey = new byte[0];
        this.digest = getSHA1DigestOrEmpty(getBytes(digest));
        this.secretKeys.putAll(secretKeys);
        if (this.digest.length == 0) {
            clear();
        }
    }

    /**
     * Initialize cryptography with a keystore.
     *
     * @param keystorePath Path to the keystore.
     * @param keystorePass Keystore password. It is also used to generate the digest for {@link #verifyKey(byte[])}
     * @param keyAlias Key alias prefix. It is suffixed with the algorithm.
     * @param keyPass Key password
     * @throws IOException
     * @throws GeneralSecurityException
     */
    public Crypto(String keystorePath, char[] keystorePass, String keyAlias, char[] keyPass)
            throws GeneralSecurityException, IOException {
        this(Crypto.getKeysFromKeyStore(keystorePath, keystorePass, keyAlias, keyPass), keystorePass);
    }

    private final static class NO_OP extends Crypto {
        private NO_OP() {
            super(new byte[0]);
        }

        @Override
        public String encrypt(String algorithm, byte[] bytesToEncrypt) throws GeneralSecurityException {
            return null;
        }

        @Override
        public byte[] decrypt(String strToDecrypt) {
            return strToDecrypt.getBytes();
        }

        @Override
        public void clear() {
            // NO OP
        }
    };

    public static final Crypto NO_OP = new NO_OP();

    protected SecretKey getSecretKey(String algorithm, byte[] key) throws NoSuchAlgorithmException {
        if (!initialized) {
            throw new RuntimeException("The Crypto object has been cleared.");
        }
        if (AES_ECB_PKCS5PADDING.equals(algorithm)) {
            algorithm = AES; // AES_ECB_PKCS5PADDING is the default for AES
        } else if (DES_ECB_PKCS5PADDING.equals(algorithm)) {
            algorithm = DES; // DES_ECB_PKCS5PADDING is the default for DES
        }
        if (!secretKeys.containsKey(algorithm)) {
            if (secretKey.length == 0) {
                throw new NoSuchAlgorithmException("Unsupported algorithm: " + algorithm);
            }
            if (AES.equals(algorithm)) { // default for AES
                key = Arrays.copyOf(getSHA1Digest(key), 16); // use a 128 bits secret key
                secretKeys.put(AES, new SecretKeySpec(key, AES));
            } else if (DES.equals(algorithm)) { // default for DES
                key = Arrays.copyOf(getSHA1Digest(key), 8); // use a 64 bits secret key
                secretKeys.put(DES, new SecretKeySpec(key, DES));
            } else {
                throw new NoSuchAlgorithmException("Unsupported algorithm: " + algorithm);
            }
        }
        return secretKeys.get(algorithm);
    }

    public byte[] getSHA1Digest(final byte[] key) throws NoSuchAlgorithmException {
        MessageDigest sha = MessageDigest.getInstance(SHA1);
        return sha.digest(key);
    }

    public byte[] getSHA1DigestOrEmpty(final byte[] bytes) {
        byte[] aDigest = new byte[0];
        try {
            aDigest = getSHA1Digest(bytes);
        } catch (NoSuchAlgorithmException e) {
            log.error(e);
        }
        return aDigest;
    }

    /**
     * @param bytesToEncrypt
     * @throws GeneralSecurityException
     */
    public String encrypt(byte[] bytesToEncrypt) throws GeneralSecurityException {
        return encrypt(null, bytesToEncrypt);
    }

    /**
     * @param algorithm cipher transformation of the form "algorithm/mode/padding" or "algorithm". See the Cipher
     *            section in the <a
     *            href=http://docs.oracle.com/javase/8/docs/technotes/guides/security/StandardNames.html#Cipher>Java
     *            Cryptography Architecture Standard Algorithm Name Documentation</a>.
     * @param bytesToEncrypt
     * @throws NoSuchPaddingException if {@code algorithm} contains a padding scheme that is not available.
     * @throws NoSuchAlgorithmException if {@code algorithm} is in an invalid or not supported format.
     * @throws GeneralSecurityException
     */
    public String encrypt(String algorithm, byte[] bytesToEncrypt) throws GeneralSecurityException {
        final String encryptedAlgo;
        if (StringUtils.isBlank(algorithm)) {
            algorithm = DEFAULT_ALGO;
            encryptedAlgo = "";
        } else {
            encryptedAlgo = Base64.encodeBase64String(algorithm.getBytes());
        }
        Cipher cipher = Cipher.getInstance(algorithm);
        cipher.init(Cipher.ENCRYPT_MODE, getSecretKey(algorithm, secretKey));
        final String encryptedString = Base64.encodeBase64String(cipher.doFinal(bytesToEncrypt));
        return String.format("{$%s$%s}", encryptedAlgo, encryptedString);
    }

    /**
     * The method returns either the decrypted {@code strToDecrypt}, either the {@code strToDecrypt} itself if it is not
     * recognized as a crypted string or if the decryption fails. The return value is a byte array for security purpose,
     * it is your responsibility to convert it then to a String or not (use of {@code char[]} is recommended).
     *
     * @param strToDecrypt
     * @return the decrypted {@code strToDecrypt} as an array of bytes, never {@code null}
     * @see #getChars(byte[])
     */
    public byte[] decrypt(String strToDecrypt) {
        Matcher matcher = CRYPTO_PATTERN.matcher(strToDecrypt);
        if (!matcher.matches()) {
            return strToDecrypt.getBytes();
        }
        Cipher decipher;
        try {
            String algorithm = new String(Base64.decodeBase64(matcher.group("algo")));
            if (StringUtils.isBlank(algorithm)) {
                algorithm = DEFAULT_ALGO;
            }
            decipher = Cipher.getInstance(algorithm);
            decipher.init(Cipher.DECRYPT_MODE, getSecretKey(algorithm, secretKey));
            final byte[] decryptedString = decipher.doFinal(Base64.decodeBase64(matcher.group("value")));
            return decryptedString;
        } catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
            log.trace("Available algorithms: " + Security.getAlgorithms("Cipher"));
            log.trace("Available security providers: " + Arrays.asList(Security.getProviders()));
            log.debug(e, e);
        } catch (InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
            log.debug(e, e);
        }
        return strToDecrypt.getBytes();
    }

    /**
     * Clear sensible values. That makes the current object unusable.
     */
    public void clear() {
        Arrays.fill(secretKey, (byte) 0);
        Arrays.fill(digest, (byte) 0);
        secretKeys.clear();
        initialized = false;
    }

    @Override
    protected void finalize() throws Throwable {
        clear();
        super.finalize();
    }

    /**
     * Test the given {@code candidateDigest} against the configured digest. In case of failure, the secret data is
     * destroyed and the object is made unusable.<br>
     * Use that method to check if some code is allowed to request that Crypto object.
     *
     * @param candidateDigest
     * @return true if {@code candidateDigest} matches the one used on creation.
     * @see #clear()
     * @see #verifyKey(char[])
     */
    public boolean verifyKey(byte[] candidateDigest) {
        boolean success = Arrays.equals(getSHA1DigestOrEmpty(candidateDigest), digest);
        if (!success) {
            clear();
        }
        return success;
    }

    /**
     * Test the given {@code candidateDigest} against the configured digest. In case of failure, the secret data is
     * destroyed and the object is made unusable.<br>
     * Use that method to check if some code is allowed to request that Crypto object.
     *
     * @param candidateDigest
     * @return true if {@code candidateDigest} matches the one used on creation.
     * @see #clear()
     * @see #verifyKey(byte[])
     */
    public boolean verifyKey(char[] candidateDigest) {
        return verifyKey(getBytes(candidateDigest));
    }

    /**
     * Utility method to get {@code byte[]} from {@code char[]} since it is recommended to store passwords in
     * {@code char[]} rather than in {@code String}.<br>
     * The default charset of this Java virtual machine is used. There can be conversion issue with unmappable
     * characters: they will be replaced with the charset's default replacement string.
     *
     * @param chars char array to convert
     * @return the byte array converted from {@code chars} using the default charset.
     */
    public static byte[] getBytes(char[] chars) {
        CharBuffer charBuffer = CharBuffer.wrap(chars);
        ByteBuffer byteBuffer = Charset.defaultCharset().encode(charBuffer);
        return Arrays.copyOfRange(byteBuffer.array(), 0, byteBuffer.limit());
    }

    /**
     * Utility method to get {@code char[]} from {@code bytes[]} since it is recommended to store passwords in
     * {@code char[]} rather than in {@code String}.<br>
     * The default charset of this Java virtual machine is used. There can be conversion issue with unmappable
     * characters: they will be replaced with the charset's default replacement string.
     *
     * @param bytes byte array to convert
     * @return the char array converted from {@code bytes} using the default charset.
     */
    public static char[] getChars(byte[] bytes) {
        ByteBuffer byteBuffer = ByteBuffer.wrap(bytes);
        CharBuffer charBuffer = Charset.defaultCharset().decode(byteBuffer);
        return Arrays.copyOfRange(charBuffer.array(), 0, charBuffer.limit());
    }

    /**
     * @param value
     * @return true if the given {@code value} is encrypted
     */
    public static boolean isEncrypted(String value) {
        return value != null && CRYPTO_PATTERN.matcher(value).matches();
    }

    /**
     * Extract secret keys from a keystore looking for {@code keyAlias + algorithm}
     *
     * @param keystorePath Path to the keystore
     * @param keystorePass Keystore password
     * @param keyAlias Key alias prefix. It is suffixed with the algorithm.
     * @param keyPass Key password
     * @throws GeneralSecurityException
     * @throws IOException
     * @see #IMPLEMENTED_ALGOS
     */
    public static Map<String, SecretKey> getKeysFromKeyStore(String keystorePath, char[] keystorePass,
            String keyAlias, char[] keyPass) throws GeneralSecurityException, IOException {
        KeyStore keystore = KeyStore.getInstance("JCEKS");
        try (InputStream keystoreStream = new FileInputStream(keystorePath)) {
            keystore.load(keystoreStream, keystorePass);
        }
        Map<String, SecretKey> secretKeys = new HashMap<>();
        for (String algo : IMPLEMENTED_ALGOS) {
            if (keystore.containsAlias(keyAlias + algo)) {
                SecretKey key = (SecretKey) keystore.getKey(keyAlias + algo, keyPass);
                secretKeys.put(algo, key);
            }
        }
        if (secretKeys.isEmpty()) {
            throw new KeyStoreException(String.format("No alias \"%s<algo>\" found in %s", keyAlias, keystorePath));
        }
        return secretKeys;
    }

    /**
     * Store a key in a keystore.<br>
     * The keystore is created if it doesn't exist.
     *
     * @param keystorePath Path to the keystore
     * @param keystorePass Keystore password
     * @param keyAlias Key alias prefix. It must be suffixed with the algorithm ({@link SecretKey#getAlgorithm()} is
     *            fine).
     * @param keyPass Key password
     * @param key
     * @throws GeneralSecurityException
     * @throws IOException
     * @see #IMPLEMENTED_ALGOS
     */
    public static void setKeyInKeyStore(String keystorePath, char[] keystorePass, String keyAlias, char[] keyPass,
            SecretKey key) throws GeneralSecurityException, IOException {
        KeyStore keystore = KeyStore.getInstance("JCEKS");
        if (!new File(keystorePath).exists()) {
            log.info("Creating a new JCEKS keystore at " + keystorePath);
            keystore.load(null);
        } else {
            try (InputStream keystoreStream = new FileInputStream(keystorePath)) {
                keystore.load(keystoreStream, keystorePass);
            }
        }
        KeyStore.SecretKeyEntry keyStoreEntry = new KeyStore.SecretKeyEntry(key);
        PasswordProtection keyPassword = new PasswordProtection(keyPass);
        keystore.setEntry(keyAlias, keyStoreEntry, keyPassword);
        try (OutputStream keystoreStream = new FileOutputStream(keystorePath)) {
            keystore.store(keystoreStream, keystorePass);
        }
    }

}