001/*
002 * (C) Copyright 2015-2017 Nuxeo (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     jcarsique
018 *     Kevin Leturc <kleturc@nuxeo.com>
019 */
020package org.nuxeo.common.codec;
021
022import java.io.File;
023import java.io.FileInputStream;
024import java.io.FileOutputStream;
025import java.io.IOException;
026import java.io.InputStream;
027import java.io.OutputStream;
028import java.nio.ByteBuffer;
029import java.nio.CharBuffer;
030import java.nio.charset.Charset;
031import java.security.GeneralSecurityException;
032import java.security.InvalidKeyException;
033import java.security.KeyStore;
034import java.security.KeyStore.PasswordProtection;
035import java.security.KeyStoreException;
036import java.security.MessageDigest;
037import java.security.NoSuchAlgorithmException;
038import java.security.Security;
039import java.util.Arrays;
040import java.util.HashMap;
041import java.util.Map;
042import java.util.regex.Matcher;
043import java.util.regex.Pattern;
044
045import javax.crypto.BadPaddingException;
046import javax.crypto.Cipher;
047import javax.crypto.IllegalBlockSizeException;
048import javax.crypto.NoSuchPaddingException;
049import javax.crypto.SecretKey;
050import javax.crypto.spec.SecretKeySpec;
051
052import org.apache.commons.codec.binary.Base64;
053import org.apache.commons.lang3.StringUtils;
054import org.apache.commons.logging.Log;
055import org.apache.commons.logging.LogFactory;
056
057/**
058 * Supported algorithms (name, keysize):
059 * <ul>
060 * <li>AES/ECB/PKCS5Padding (128)</li>
061 * <li>DES/ECB/PKCS5Padding (64)</li>
062 * <ul/>
063 *
064 * @since 7.4
065 */
066public class Crypto {
067
068    protected static final Pattern CRYPTO_PATTERN = Pattern.compile("\\{\\$(?<algo>.*)\\$(?<value>.+)\\}");
069
070    private static final Log log = LogFactory.getLog(Crypto.class);
071
072    public static final String AES = "AES";
073
074    public static final String AES_ECB_PKCS5PADDING = "AES/ECB/PKCS5Padding";
075
076    public static final String DES = "DES";
077
078    public static final String DES_ECB_PKCS5PADDING = "DES/ECB/PKCS5Padding";
079
080    public static final String[] IMPLEMENTED_ALGOS = { AES, DES, AES_ECB_PKCS5PADDING, DES_ECB_PKCS5PADDING };
081
082    public static final String DEFAULT_ALGO = AES_ECB_PKCS5PADDING;
083
084    private static final String SHA1 = "SHA-1";
085
086    private final byte[] secretKey;
087
088    private final Map<String, SecretKey> secretKeys = new HashMap<>();
089
090    private boolean initialized = true;
091
092    private final byte[] digest;
093
094    public Crypto(byte[] secretKey) {
095        this.secretKey = secretKey;
096        digest = getSHA1DigestOrEmpty(secretKey);
097        if (digest.length == 0) {
098            clear();
099        }
100    }
101
102    /**
103     * Initialize cryptography with a map of {@link SecretKey}.
104     *
105     * @param secretKeys Map of {@code SecretKey} per algorithm
106     */
107    public Crypto(Map<String, SecretKey> secretKeys) {
108        this(secretKeys, Crypto.class.getName().toCharArray());
109    }
110
111    /**
112     * Initialize cryptography with a map of {@link SecretKey}.
113     *
114     * @param digest Digest for later use by {@link #verifyKey(byte[])}
115     * @param secretKeys Map of {@code SecretKey} per algorithm
116     */
117    public Crypto(Map<String, SecretKey> secretKeys, char[] digest) {
118        secretKey = new byte[0];
119        this.digest = getSHA1DigestOrEmpty(getBytes(digest));
120        this.secretKeys.putAll(secretKeys);
121        if (this.digest.length == 0) {
122            clear();
123        }
124    }
125
126    /**
127     * Initialize cryptography with a keystore.
128     *
129     * @param keystorePath Path to the keystore.
130     * @param keystorePass Keystore password. It is also used to generate the digest for {@link #verifyKey(byte[])}
131     * @param keyAlias Key alias prefix. It is suffixed with the algorithm.
132     * @param keyPass Key password
133     * @throws IOException
134     * @throws GeneralSecurityException
135     */
136    public Crypto(String keystorePath, char[] keystorePass, String keyAlias, char[] keyPass)
137            throws GeneralSecurityException, IOException {
138        this(Crypto.getKeysFromKeyStore(keystorePath, keystorePass, keyAlias, keyPass), keystorePass);
139    }
140
141    private final static class NO_OP extends Crypto {
142        private NO_OP() {
143            super(new byte[0]);
144        }
145
146        @Override
147        public String encrypt(String algorithm, byte[] bytesToEncrypt) throws GeneralSecurityException {
148            return null;
149        }
150
151        @Override
152        public byte[] decrypt(String strToDecrypt) {
153            return strToDecrypt.getBytes();
154        }
155
156        @Override
157        public void clear() {
158            // NO OP
159        }
160    }
161
162    public static final Crypto NO_OP = new NO_OP();
163
164    protected SecretKey getSecretKey(String algorithm, byte[] key) throws NoSuchAlgorithmException {
165        if (!initialized) {
166            throw new RuntimeException("The Crypto object has been cleared.");
167        }
168        if (AES_ECB_PKCS5PADDING.equals(algorithm)) {
169            algorithm = AES; // AES_ECB_PKCS5PADDING is the default for AES
170        } else if (DES_ECB_PKCS5PADDING.equals(algorithm)) {
171            algorithm = DES; // DES_ECB_PKCS5PADDING is the default for DES
172        }
173        if (!secretKeys.containsKey(algorithm)) {
174            if (secretKey.length == 0) {
175                throw new NoSuchAlgorithmException("Unsupported algorithm: " + algorithm);
176            }
177            if (AES.equals(algorithm)) { // default for AES
178                key = Arrays.copyOf(getSHA1Digest(key), 16); // use a 128 bits secret key
179                secretKeys.put(AES, new SecretKeySpec(key, AES));
180            } else if (DES.equals(algorithm)) { // default for DES
181                key = Arrays.copyOf(getSHA1Digest(key), 8); // use a 64 bits secret key
182                secretKeys.put(DES, new SecretKeySpec(key, DES));
183            } else {
184                throw new NoSuchAlgorithmException("Unsupported algorithm: " + algorithm);
185            }
186        }
187        return secretKeys.get(algorithm);
188    }
189
190    public byte[] getSHA1Digest(final byte[] key) throws NoSuchAlgorithmException {
191        MessageDigest sha = MessageDigest.getInstance(SHA1);
192        return sha.digest(key);
193    }
194
195    public byte[] getSHA1DigestOrEmpty(final byte[] bytes) {
196        byte[] aDigest = new byte[0];
197        try {
198            aDigest = getSHA1Digest(bytes);
199        } catch (NoSuchAlgorithmException e) {
200            log.error(e);
201        }
202        return aDigest;
203    }
204
205    public String encrypt(byte[] bytesToEncrypt) throws GeneralSecurityException {
206        return encrypt(null, bytesToEncrypt);
207    }
208
209    /**
210     * @param algorithm cipher transformation of the form "algorithm/mode/padding" or "algorithm". See the Cipher
211     *            section in the <a
212     *            href=http://docs.oracle.com/javase/8/docs/technotes/guides/security/StandardNames.html#Cipher>Java
213     *            Cryptography Architecture Standard Algorithm Name Documentation</a>.
214     * @throws NoSuchPaddingException if {@code algorithm} contains a padding scheme that is not available.
215     * @throws NoSuchAlgorithmException if {@code algorithm} is in an invalid or not supported format.
216     * @throws GeneralSecurityException
217     */
218    public String encrypt(String algorithm, byte[] bytesToEncrypt) throws GeneralSecurityException {
219        final String encryptedAlgo;
220        if (StringUtils.isBlank(algorithm)) {
221            algorithm = DEFAULT_ALGO;
222            encryptedAlgo = "";
223        } else {
224            encryptedAlgo = Base64.encodeBase64String(algorithm.getBytes());
225        }
226        Cipher cipher = Cipher.getInstance(algorithm);
227        cipher.init(Cipher.ENCRYPT_MODE, getSecretKey(algorithm, secretKey));
228        final String encryptedString = Base64.encodeBase64String(cipher.doFinal(bytesToEncrypt));
229        return String.format("{$%s$%s}", encryptedAlgo, encryptedString);
230    }
231
232    /**
233     * The method returns either the decrypted {@code strToDecrypt}, either the {@code strToDecrypt} itself if it is not
234     * recognized as a crypted string or if the decryption fails. The return value is a byte array for security purpose,
235     * it is your responsibility to convert it then to a String or not (use of {@code char[]} is recommended).
236     *
237     * @return the decrypted {@code strToDecrypt} as an array of bytes, never {@code null}
238     * @see #getChars(byte[])
239     */
240    public byte[] decrypt(String strToDecrypt) {
241        Matcher matcher = CRYPTO_PATTERN.matcher(strToDecrypt);
242        if (!matcher.matches()) {
243            return strToDecrypt.getBytes();
244        }
245        Cipher decipher;
246        try {
247            String algorithm = new String(Base64.decodeBase64(matcher.group("algo")));
248            if (StringUtils.isBlank(algorithm)) {
249                algorithm = DEFAULT_ALGO;
250            }
251            decipher = Cipher.getInstance(algorithm);
252            decipher.init(Cipher.DECRYPT_MODE, getSecretKey(algorithm, secretKey));
253            return decipher.doFinal(Base64.decodeBase64(matcher.group("value")));
254        } catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
255            log.trace("Available algorithms: " + Security.getAlgorithms("Cipher"));
256            log.trace("Available security providers: " + Arrays.asList(Security.getProviders()));
257            log.debug(e, e);
258        } catch (InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
259            log.debug(e, e);
260        }
261        return strToDecrypt.getBytes();
262    }
263
264    /**
265     * Clear sensible values. That makes the current object unusable.
266     */
267    public void clear() {
268        Arrays.fill(secretKey, (byte) 0);
269        Arrays.fill(digest, (byte) 0);
270        secretKeys.clear();
271        initialized = false;
272    }
273
274    @Override
275    protected void finalize() throws Throwable {
276        clear();
277        super.finalize();
278    }
279
280    /**
281     * Test the given {@code candidateDigest} against the configured digest. In case of failure, the secret data is
282     * destroyed and the object is made unusable.<br>
283     * Use that method to check if some code is allowed to request that Crypto object.
284     *
285     * @return true if {@code candidateDigest} matches the one used on creation.
286     * @see #clear()
287     * @see #verifyKey(char[])
288     */
289    public boolean verifyKey(byte[] candidateDigest) {
290        boolean success = Arrays.equals(getSHA1DigestOrEmpty(candidateDigest), digest);
291        if (!success) {
292            clear();
293        }
294        return success;
295    }
296
297    /**
298     * Test the given {@code candidateDigest} against the configured digest. In case of failure, the secret data is
299     * destroyed and the object is made unusable.<br>
300     * Use that method to check if some code is allowed to request that Crypto object.
301     *
302     * @return true if {@code candidateDigest} matches the one used on creation.
303     * @see #clear()
304     * @see #verifyKey(byte[])
305     */
306    public boolean verifyKey(char[] candidateDigest) {
307        return verifyKey(getBytes(candidateDigest));
308    }
309
310    /**
311     * Utility method to get {@code byte[]} from {@code char[]} since it is recommended to store passwords in
312     * {@code char[]} rather than in {@code String}.<br>
313     * The default charset of this Java virtual machine is used. There can be conversion issue with unmappable
314     * characters: they will be replaced with the charset's default replacement string.
315     *
316     * @param chars char array to convert
317     * @return the byte array converted from {@code chars} using the default charset.
318     */
319    public static byte[] getBytes(char[] chars) {
320        CharBuffer charBuffer = CharBuffer.wrap(chars);
321        ByteBuffer byteBuffer = Charset.defaultCharset().encode(charBuffer);
322        return Arrays.copyOfRange(byteBuffer.array(), 0, byteBuffer.limit());
323    }
324
325    /**
326     * Utility method to get {@code char[]} from {@code bytes[]} since it is recommended to store passwords in
327     * {@code char[]} rather than in {@code String}.<br>
328     * The default charset of this Java virtual machine is used. There can be conversion issue with unmappable
329     * characters: they will be replaced with the charset's default replacement string.
330     *
331     * @param bytes byte array to convert
332     * @return the char array converted from {@code bytes} using the default charset.
333     */
334    public static char[] getChars(byte[] bytes) {
335        ByteBuffer byteBuffer = ByteBuffer.wrap(bytes);
336        CharBuffer charBuffer = Charset.defaultCharset().decode(byteBuffer);
337        return Arrays.copyOfRange(charBuffer.array(), 0, charBuffer.limit());
338    }
339
340    /**
341     * @return true if the given {@code value} is encrypted
342     */
343    public static boolean isEncrypted(String value) {
344        return value != null && CRYPTO_PATTERN.matcher(value).matches();
345    }
346
347    /**
348     * Extract secret keys from a keystore looking for {@code keyAlias + algorithm}
349     *
350     * @param keystorePath Path to the keystore
351     * @param keystorePass Keystore password
352     * @param keyAlias Key alias prefix. It is suffixed with the algorithm.
353     * @param keyPass Key password
354     * @throws GeneralSecurityException
355     * @throws IOException
356     * @see #IMPLEMENTED_ALGOS
357     */
358    public static Map<String, SecretKey> getKeysFromKeyStore(String keystorePath, char[] keystorePass, String keyAlias,
359            char[] keyPass) throws GeneralSecurityException, IOException {
360        KeyStore keystore = KeyStore.getInstance("JCEKS");
361        try (InputStream keystoreStream = new FileInputStream(keystorePath)) {
362            keystore.load(keystoreStream, keystorePass);
363        }
364        Map<String, SecretKey> secretKeys = new HashMap<>();
365        for (String algo : IMPLEMENTED_ALGOS) {
366            if (keystore.containsAlias(keyAlias + algo)) {
367                SecretKey key = (SecretKey) keystore.getKey(keyAlias + algo, keyPass);
368                secretKeys.put(algo, key);
369            }
370        }
371        if (secretKeys.isEmpty()) {
372            throw new KeyStoreException(String.format("No alias \"%s<algo>\" found in %s", keyAlias, keystorePath));
373        }
374        return secretKeys;
375    }
376
377    /**
378     * Store a key in a keystore.<br>
379     * The keystore is created if it doesn't exist.
380     *
381     * @param keystorePath Path to the keystore
382     * @param keystorePass Keystore password
383     * @param keyAlias Key alias prefix. It must be suffixed with the algorithm ({@link SecretKey#getAlgorithm()} is
384     *            fine).
385     * @param keyPass Key password
386     * @throws GeneralSecurityException
387     * @throws IOException
388     * @see #IMPLEMENTED_ALGOS
389     */
390    public static void setKeyInKeyStore(String keystorePath, char[] keystorePass, String keyAlias, char[] keyPass,
391            SecretKey key) throws GeneralSecurityException, IOException {
392        KeyStore keystore = KeyStore.getInstance("JCEKS");
393        if (!new File(keystorePath).exists()) {
394            log.info("Creating a new JCEKS keystore at " + keystorePath);
395            keystore.load(null);
396        } else {
397            try (InputStream keystoreStream = new FileInputStream(keystorePath)) {
398                keystore.load(keystoreStream, keystorePass);
399            }
400        }
401        KeyStore.SecretKeyEntry keyStoreEntry = new KeyStore.SecretKeyEntry(key);
402        PasswordProtection keyPassword = new PasswordProtection(keyPass);
403        keystore.setEntry(keyAlias, keyStoreEntry, keyPassword);
404        try (OutputStream keystoreStream = new FileOutputStream(keystorePath)) {
405            keystore.store(keystoreStream, keystorePass);
406        }
407    }
408
409}