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