001/*
002 * (C) Copyright 2006-2014 Nuxeo SA (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 *     Florent Guillaume
018 */
019package org.nuxeo.ecm.core.blob.binary;
020
021import java.io.BufferedInputStream;
022import java.io.BufferedOutputStream;
023import java.io.DataInputStream;
024import java.io.DataOutputStream;
025import java.io.File;
026import java.io.FileInputStream;
027import java.io.FileOutputStream;
028import java.io.FilterOutputStream;
029import java.io.IOException;
030import java.io.InputStream;
031import java.io.OutputStream;
032import java.lang.reflect.Field;
033import java.security.GeneralSecurityException;
034import java.security.Key;
035import java.security.KeyStore;
036import java.security.MessageDigest;
037import java.security.SecureRandom;
038import java.util.Arrays;
039import java.util.Map;
040import java.util.Random;
041
042import javax.crypto.BadPaddingException;
043import javax.crypto.Cipher;
044import javax.crypto.CipherInputStream;
045import javax.crypto.SecretKeyFactory;
046import javax.crypto.spec.IvParameterSpec;
047import javax.crypto.spec.PBEKeySpec;
048import javax.crypto.spec.SecretKeySpec;
049
050import org.apache.commons.io.IOUtils;
051import org.apache.commons.lang.StringUtils;
052import org.apache.commons.logging.Log;
053import org.apache.commons.logging.LogFactory;
054import org.nuxeo.ecm.core.api.NuxeoException;
055import org.nuxeo.runtime.api.Framework;
056
057/**
058 * A binary manager that encrypts binaries on the filesystem using AES.
059 * <p>
060 * The configuration holds the keystore information to retrieve the AES key, or the
061 * password that is used to generate a per-file key using PBKDF2. This configuration comes from the
062 * {@code <property name="key">...</property>} of the binary manager configuration.
063 * <p>
064 * The configuration has the form {@code key1=value1,key2=value2,...} where the possible keys are, for keystore use:
065 * <ul>
066 * <li>keyStoreType: the keystore type, for instance JCEKS
067 * <li>keyStoreFile: the path to the keystore, if applicable
068 * <li>keyStorePassword: the keystore password
069 * <li>keyAlias: the alias (name) of the key in the keystore
070 * <li>keyPassword: the key password
071 * </ul>
072 * <p>
073 * And for PBKDF2 use:
074 * <ul>
075 * <li>password: the password
076 * </ul>
077 * <p>
078 * To encrypt a binary, an AES key is needed. This key can be retrieved from a keystore, or generated from a password
079 * using PBKDF2 (in which case each stored file contains a different salt for security reasons). The file format is
080 * described in {@link #storeAndDigest(InputStream, OutputStream)}.
081 * <p>
082 * While the binary is being used by the application, a temporarily-decrypted file is held in a temporary directory. It
083 * is removed as soon as possible.
084 * <p>
085 * Note: if the Java Cryptographic Extension (JCE) is not configured for 256-bit key length, you may get an exception
086 * "java.security.InvalidKeyException: Illegal key size or default parameters". If this is the case, go to <a
087 * href="http://www.oracle.com/technetwork/java/javase/downloads/index.html" >Oracle Java SE Downloads</a> and download
088 * and install the Java Cryptography Extension (JCE) Unlimited Strength Jurisdiction Policy Files for your JDK.
089 *
090 * @since 6.0
091 */
092public class AESBinaryManager extends LocalBinaryManager {
093
094    private static final Log log = LogFactory.getLog(AESBinaryManager.class);
095
096    protected static final byte[] FILE_MAGIC = new byte[] { 'N', 'U', 'X', 'E', 'O', 'C', 'R', 'Y', 'P', 'T' };
097
098    protected static final int FILE_VERSION_1 = 1;
099
100    protected static final int USE_KEYSTORE = 1;
101
102    protected static final int USE_PBKDF2 = 2;
103
104    protected static final String AES = "AES";
105
106    protected static final String AES_CBC_PKCS5_PADDING = "AES/CBC/PKCS5Padding";
107
108    protected static final String PBKDF2_WITH_HMAC_SHA1 = "PBKDF2WithHmacSHA1";
109
110    protected static final int PBKDF2_ITERATIONS = 10000;
111
112    // AES-256
113    protected static final int PBKDF2_KEY_LENGTH = 256;
114
115    protected static final String PARAM_PASSWORD = "password";
116
117    protected static final String PARAM_KEY_STORE_TYPE = "keyStoreType";
118
119    protected static final String PARAM_KEY_STORE_FILE = "keyStoreFile";
120
121    protected static final String PARAM_KEY_STORE_PASSWORD = "keyStorePassword";
122
123    protected static final String PARAM_KEY_ALIAS = "keyAlias";
124
125    protected static final String PARAM_KEY_PASSWORD = "keyPassword";
126
127    // for sanity check during reads
128    private static final int MAX_SALT_LEN = 1024;
129
130    // for sanity check during reads
131    private static final int MAX_IV_LEN = 1024;
132
133    // Random instances are thread-safe
134    protected static final Random RANDOM = new SecureRandom();
135
136    // the digest from the root descriptor
137    protected String digestAlgorithm;
138
139    protected boolean usePBKDF2;
140
141    protected String password;
142
143    protected String keyStoreType;
144
145    protected String keyStoreFile;
146
147    protected String keyStorePassword;
148
149    protected String keyAlias;
150
151    protected String keyPassword;
152
153    public AESBinaryManager() {
154        setUnlimitedJCEPolicy();
155    }
156
157    /**
158     * By default the JRE may ship with restricted key length. Instead of having administrators download the Java
159     * Cryptography Extension (JCE) Unlimited Strength Jurisdiction Policy Files from
160     * http://www.oracle.com/technetwork/java/javase/downloads/index.html, we attempt to directly unrestrict the JCE
161     * using reflection.
162     * <p>
163     * This is not possible anymore since 8u102 and https://bugs.openjdk.java.net/browse/JDK-8149417
164     */
165    protected static boolean setUnlimitedJCEPolicy() {
166        try {
167            Field field = Class.forName("javax.crypto.JceSecurity").getDeclaredField("isRestricted");
168            field.setAccessible(true);
169            if (Boolean.TRUE.equals(field.get(null))) {
170                log.info("Setting JCE Unlimited Strength");
171                field.set(null, Boolean.FALSE);
172            }
173            return true;
174        } catch (ReflectiveOperationException | SecurityException | IllegalArgumentException e) {
175            log.debug("Cannot check/set JCE Unlimited Strength", e);
176            return false;
177        }
178    }
179
180    @Override
181    public void initialize(String blobProviderId, Map<String, String> properties) throws IOException {
182        super.initialize(blobProviderId, properties);
183        digestAlgorithm = getDigestAlgorithm();
184        String options = properties.get(BinaryManager.PROP_KEY);
185        // TODO parse options from properties directly
186        if (StringUtils.isBlank(options)) {
187            throw new NuxeoException("Missing key for " + getClass().getSimpleName());
188        }
189        initializeOptions(options);
190    }
191
192    protected void initializeOptions(String options) {
193        for (String option : options.split(",")) {
194            String[] split = option.split("=", 2);
195            if (split.length != 2) {
196                throw new NuxeoException("Unrecognized option: " + option);
197            }
198            String value = StringUtils.defaultIfBlank(split[1], null);
199            switch (split[0]) {
200            case PARAM_PASSWORD:
201                password = value;
202                break;
203            case PARAM_KEY_STORE_TYPE:
204                keyStoreType = value;
205                break;
206            case PARAM_KEY_STORE_FILE:
207                keyStoreFile = value;
208                break;
209            case PARAM_KEY_STORE_PASSWORD:
210                keyStorePassword = value;
211                break;
212            case PARAM_KEY_ALIAS:
213                keyAlias = value;
214                break;
215            case PARAM_KEY_PASSWORD:
216                keyPassword = value;
217                break;
218            default:
219                throw new NuxeoException("Unrecognized option: " + option);
220            }
221        }
222        usePBKDF2 = password != null;
223        if (usePBKDF2) {
224            if (keyStoreType != null) {
225                throw new NuxeoException("Cannot use " + PARAM_KEY_STORE_TYPE + " with " + PARAM_PASSWORD);
226            }
227            if (keyStoreFile != null) {
228                throw new NuxeoException("Cannot use " + PARAM_KEY_STORE_FILE + " with " + PARAM_PASSWORD);
229            }
230            if (keyStorePassword != null) {
231                throw new NuxeoException("Cannot use " + PARAM_KEY_STORE_PASSWORD + " with " + PARAM_PASSWORD);
232            }
233            if (keyAlias != null) {
234                throw new NuxeoException("Cannot use " + PARAM_KEY_ALIAS + " with " + PARAM_PASSWORD);
235            }
236            if (keyPassword != null) {
237                throw new NuxeoException("Cannot use " + PARAM_KEY_PASSWORD + " with " + PARAM_PASSWORD);
238            }
239        } else {
240            if (keyStoreType == null) {
241                throw new NuxeoException("Missing " + PARAM_KEY_STORE_TYPE);
242            }
243            // keystore file is optional
244            if (keyStoreFile == null && keyStorePassword != null) {
245                throw new NuxeoException("Missing " + PARAM_KEY_STORE_PASSWORD);
246            }
247            if (keyAlias == null) {
248                throw new NuxeoException("Missing " + PARAM_KEY_ALIAS);
249            }
250            if (keyPassword == null) {
251                keyPassword = keyStorePassword;
252            }
253        }
254    }
255
256    /**
257     * Gets the password for PBKDF2.
258     * <p>
259     * The caller must clear it from memory when done with it by calling {@link #clearPassword}.
260     */
261    protected char[] getPassword() {
262        return password.toCharArray();
263    }
264
265    /**
266     * Clears a password from memory.
267     */
268    protected void clearPassword(char[] password) {
269        if (password != null) {
270            Arrays.fill(password, '\0');
271        }
272    }
273
274    /**
275     * Generates an AES key from the password using PBKDF2.
276     *
277     * @param salt the salt
278     */
279    protected Key generateSecretKey(byte[] salt) throws GeneralSecurityException {
280        char[] password = getPassword();
281        SecretKeyFactory factory = SecretKeyFactory.getInstance(PBKDF2_WITH_HMAC_SHA1);
282        PBEKeySpec spec = new PBEKeySpec(password, salt, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH);
283        clearPassword(password);
284        Key derived = factory.generateSecret(spec);
285        spec.clearPassword();
286        return new SecretKeySpec(derived.getEncoded(), AES);
287    }
288
289    /**
290     * Gets the AES key from the keystore.
291     */
292    protected Key getSecretKey() throws GeneralSecurityException, IOException {
293        KeyStore keyStore = KeyStore.getInstance(keyStoreType);
294        char[] kspw = keyStorePassword == null ? null : keyStorePassword.toCharArray();
295        if (keyStoreFile != null) {
296            try (InputStream in = new BufferedInputStream(new FileInputStream(keyStoreFile))) {
297                keyStore.load(in, kspw);
298            }
299        } else {
300            // some keystores are not backed by a file
301            keyStore.load(null, kspw);
302        }
303        clearPassword(kspw);
304        char[] kpw = keyPassword == null ? null : keyPassword.toCharArray();
305        Key key = keyStore.getKey(keyAlias, kpw);
306        clearPassword(kpw);
307        return key;
308    }
309
310    @Override
311    protected Binary getBinary(InputStream in) throws IOException {
312        // write to a tmp file that will be used by the returned Binary
313        // TODO if stream source, avoid copy (no-copy optimization)
314        File tmp = File.createTempFile("bin_", ".tmp", tmpDir);
315        Framework.trackFile(tmp, tmp);
316        OutputStream out = new BufferedOutputStream(new FileOutputStream(tmp));
317        IOUtils.copy(in, out);
318        in.close();
319        out.close();
320        // encrypt an digest into final file
321        InputStream nin = new BufferedInputStream(new FileInputStream(tmp));
322        String digest = storeAndDigest(nin); // calls our storeAndDigest
323        // return a binary on our tmp file
324        return new Binary(tmp, digest, blobProviderId);
325    }
326
327    @Override
328    public Binary getBinary(String digest) {
329        File file = getFileForDigest(digest, false);
330        if (file == null) {
331            log.warn("Invalid digest format: " + digest);
332            return null;
333        }
334        if (!file.exists()) {
335            return null;
336        }
337        File tmp;
338        try {
339            tmp = File.createTempFile("bin_", ".tmp", tmpDir);
340            Framework.trackFile(tmp, tmp);
341            OutputStream out = new BufferedOutputStream(new FileOutputStream(tmp));
342            InputStream in = new BufferedInputStream(new FileInputStream(file));
343            try {
344                decrypt(in, out);
345            } finally {
346                in.close();
347                out.close();
348            }
349        } catch (IOException e) {
350            throw new RuntimeException(e);
351        }
352        // return a binary on our tmp file
353        return new Binary(tmp, digest, blobProviderId);
354    }
355
356    @Override
357    protected String storeAndDigest(InputStream in) throws IOException {
358        File tmp = File.createTempFile("create_", ".tmp", tmpDir);
359        OutputStream out = new BufferedOutputStream(new FileOutputStream(tmp));
360        /*
361         * First, write the input stream to a temporary file, while computing a digest.
362         */
363        try {
364            String digest;
365            try {
366                digest = storeAndDigest(in, out);
367            } finally {
368                in.close();
369                out.close();
370            }
371            /*
372             * Move the tmp file to its destination.
373             */
374            File file = getFileForDigest(digest, true);
375            atomicMove(tmp, file);
376            return digest;
377        } finally {
378            tmp.delete();
379        }
380    }
381
382    /**
383     * Encrypts the given input stream into the given output stream, while also computing the digest of the input
384     * stream.
385     * <p>
386     * File format version 1 (values are in network order):
387     * <ul>
388     * <li>10 bytes: magic number "NUXEOCRYPT"
389     * <li>1 byte: file format version = 1
390     * <li>1 byte: use keystore = 1, use PBKDF2 = 2
391     * <li>if use PBKDF2:
392     * <ul>
393     * <li>4 bytes: salt length = n
394     * <li>n bytes: salt data
395     * </ul>
396     * <li>4 bytes: IV length = p
397     * <li>p bytes: IV data
398     * <li>x bytes: encrypted stream
399     * </ul>
400     *
401     * @param in the input stream containing the data
402     * @param file the file containing the encrypted data
403     * @return the digest of the input stream
404     */
405    @Override
406    public String storeAndDigest(InputStream in, OutputStream out) throws IOException {
407        out.write(FILE_MAGIC);
408        DataOutputStream data = new DataOutputStream(out);
409        data.writeByte(FILE_VERSION_1);
410
411        try {
412            // get digest to use
413            MessageDigest messageDigest = MessageDigest.getInstance(digestAlgorithm);
414
415            // secret key
416            Key secret;
417            if (usePBKDF2) {
418                data.writeByte(USE_PBKDF2);
419                // generate a salt
420                byte[] salt = new byte[16];
421                RANDOM.nextBytes(salt);
422                // generate secret key
423                secret = generateSecretKey(salt);
424                // write salt
425                data.writeInt(salt.length);
426                data.write(salt);
427            } else {
428                data.writeByte(USE_KEYSTORE);
429                // find secret key from keystore
430                secret = getSecretKey();
431            }
432
433            // cipher
434            Cipher cipher = Cipher.getInstance(AES_CBC_PKCS5_PADDING);
435            cipher.init(Cipher.ENCRYPT_MODE, secret);
436
437            // write IV
438            byte[] iv = cipher.getIV();
439            data.writeInt(iv.length);
440            data.write(iv);
441
442            // digest and write the encrypted data
443            CipherAndDigestOutputStream cipherOut = new CipherAndDigestOutputStream(out, cipher, messageDigest);
444            IOUtils.copy(in, cipherOut);
445            cipherOut.close();
446            byte[] digest = cipherOut.getDigest();
447            return toHexString(digest);
448        } catch (GeneralSecurityException e) {
449            throw new NuxeoException(e);
450        }
451
452    }
453
454    /**
455     * Decrypts the given input stream into the given output stream.
456     */
457    protected void decrypt(InputStream in, OutputStream out) throws IOException {
458        byte[] magic = new byte[FILE_MAGIC.length];
459        IOUtils.read(in, magic);
460        if (!Arrays.equals(magic, FILE_MAGIC)) {
461            throw new IOException("Invalid file (bad magic)");
462        }
463        DataInputStream data = new DataInputStream(in);
464        byte magicvers = data.readByte();
465        if (magicvers != FILE_VERSION_1) {
466            throw new IOException("Invalid file (bad version)");
467        }
468
469        byte usepb = data.readByte();
470        if (usepb == USE_PBKDF2) {
471            if (!usePBKDF2) {
472                throw new NuxeoException("File requires PBKDF2 password");
473            }
474        } else if (usepb == USE_KEYSTORE) {
475            if (usePBKDF2) {
476                throw new NuxeoException("File requires keystore");
477            }
478        } else {
479            throw new IOException("Invalid file (bad use)");
480        }
481
482        try {
483            // secret key
484            Key secret;
485            if (usePBKDF2) {
486                // read salt first
487                int saltLen = data.readInt();
488                if (saltLen <= 0 || saltLen > MAX_SALT_LEN) {
489                    throw new NuxeoException("Invalid salt length: " + saltLen);
490                }
491                byte[] salt = new byte[saltLen];
492                data.read(salt, 0, saltLen);
493                secret = generateSecretKey(salt);
494            } else {
495                secret = getSecretKey();
496            }
497
498            // read IV
499            int ivLen = data.readInt();
500            if (ivLen <= 0 || ivLen > MAX_IV_LEN) {
501                throw new NuxeoException("Invalid IV length: " + ivLen);
502            }
503            byte[] iv = new byte[ivLen];
504            data.read(iv, 0, ivLen);
505
506            // cipher
507            Cipher cipher;
508            cipher = Cipher.getInstance(AES_CBC_PKCS5_PADDING);
509            cipher.init(Cipher.DECRYPT_MODE, secret, new IvParameterSpec(iv));
510
511            // read the encrypted data
512            try (InputStream cipherIn = new CipherInputStream(in, cipher)) {
513                IOUtils.copy(cipherIn, out);
514            } catch (IOException e) {
515                Throwable cause = e.getCause();
516                if (cause != null && cause instanceof BadPaddingException) {
517                    throw new NuxeoException(cause.getMessage(), e);
518                }
519            }
520        } catch (GeneralSecurityException e) {
521            throw new NuxeoException(e);
522        }
523    }
524
525    /**
526     * A {@link javax.crypto.CipherOutputStream CipherOutputStream} that also does a digest of the original stream at
527     * the same time.
528     */
529    public static class CipherAndDigestOutputStream extends FilterOutputStream {
530
531        protected Cipher cipher;
532
533        protected OutputStream out;
534
535        protected MessageDigest messageDigest;
536
537        protected byte[] digest;
538
539        public CipherAndDigestOutputStream(OutputStream out, Cipher cipher, MessageDigest messageDigest) {
540            super(out);
541            this.out = out;
542            this.cipher = cipher;
543            this.messageDigest = messageDigest;
544        }
545
546        public byte[] getDigest() {
547            return digest;
548        }
549
550        @Override
551        public void write(int b) throws IOException {
552            write(new byte[] { (byte) b }, 0, 1);
553        }
554
555        @Override
556        public void write(byte b[], int off, int len) throws IOException {
557            messageDigest.update(b, off, len);
558            byte[] bytes = cipher.update(b, off, len);
559            if (bytes != null) {
560                out.write(bytes);
561                bytes = null; // help GC
562            }
563        }
564
565        @Override
566        public void flush() throws IOException {
567            out.flush();
568        }
569
570        @Override
571        public void close() throws IOException {
572            digest = messageDigest.digest();
573            try {
574                byte[] bytes = cipher.doFinal();
575                out.write(bytes);
576                bytes = null; // help GC
577            } catch (GeneralSecurityException e) {
578                throw new NuxeoException(e);
579            }
580            try {
581                flush();
582            } finally {
583                out.close();
584            }
585        }
586    }
587
588}