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