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