001/*
002 * (C) Copyright 2019 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;
020
021import static org.apache.commons.lang3.StringUtils.defaultIfBlank;
022import static org.apache.commons.lang3.StringUtils.isBlank;
023
024import java.io.BufferedInputStream;
025import java.io.IOException;
026import java.io.InputStream;
027import java.nio.file.Files;
028import java.nio.file.Paths;
029import java.security.GeneralSecurityException;
030import java.security.Key;
031import java.security.KeyStore;
032import java.security.spec.AlgorithmParameterSpec;
033import java.util.Arrays;
034import java.util.Map;
035
036import javax.crypto.Cipher;
037import javax.crypto.SecretKeyFactory;
038import javax.crypto.spec.GCMParameterSpec;
039import javax.crypto.spec.IvParameterSpec;
040import javax.crypto.spec.PBEKeySpec;
041import javax.crypto.spec.SecretKeySpec;
042
043import org.apache.logging.log4j.LogManager;
044import org.apache.logging.log4j.Logger;
045import org.nuxeo.ecm.core.api.NuxeoException;
046import org.nuxeo.runtime.api.Framework;
047
048/**
049 * Configuration for the AES-encrypted storage of files.
050 *
051 * @since 11.1
052 */
053public class AESBlobStoreConfiguration extends PropertyBasedConfiguration {
054
055    private static final Logger log = LogManager.getLogger(AESBlobStoreConfiguration.class);
056
057    protected static final String AES = "AES";
058
059    protected static final String PBKDF2_WITH_HMAC_SHA1 = "PBKDF2WithHmacSHA1";
060
061    protected static final int PBKDF2_ITERATIONS = 10000;
062
063    // AES-256
064    protected static final int PBKDF2_KEY_LENGTH = 256;
065
066    // insecure, see https://find-sec-bugs.github.io/bugs.htm#PADDING_ORACLE
067    protected static final String AES_CBC_PKCS5_PADDING = "AES/CBC/PKCS5Padding";
068
069    protected static final String AES_GCM_NOPADDING = "AES/GCM/NoPadding";
070
071    public static final String PROP_COMPAT_KEY = "key";
072
073    public static final String PROP_PASSWORD = "password";
074
075    public static final String PROP_KEY_STORE_TYPE = "keyStoreType";
076
077    public static final String PROP_KEY_STORE_FILE = "keyStoreFile";
078
079    public static final String PROP_KEY_STORE_PASSWORD = "keyStorePassword";
080
081    public static final String PROP_KEY_ALIAS = "keyAlias";
082
083    public static final String PROP_KEY_PASSWORD = "keyPassword";
084
085    /**
086     * If {@code true}, use the insecure AES/CBC/PKCS5Padding for encryption. The default is {@code false}, to use
087     * AES/GCM/NoPadding.
088     */
089    public static final String PROP_KEY_USE_INSECURE_CIPHER = "useInsecureCipher";
090
091    public final boolean usePBKDF2;
092
093    public final String password;
094
095    public final String keyStoreType;
096
097    public final String keyStoreFile;
098
099    public final String keyStorePassword;
100
101    public final String keyAlias;
102
103    public final String keyPassword;
104
105    public final boolean useInsecureCipher;
106
107    public AESBlobStoreConfiguration(Map<String, String> properties) throws IOException {
108        super(null, properties);
109        parseCompat();
110        password = getProperty(PROP_PASSWORD);
111        keyStoreType = getProperty(PROP_KEY_STORE_TYPE);
112        keyStoreFile = getProperty(PROP_KEY_STORE_FILE);
113        keyStorePassword = getProperty(PROP_KEY_STORE_PASSWORD);
114        keyAlias = getProperty(PROP_KEY_ALIAS);
115        String keyPassword = getProperty(PROP_KEY_PASSWORD); // NOSONAR
116        useInsecureCipher = Boolean.parseBoolean(getProperty(PROP_KEY_USE_INSECURE_CIPHER));
117
118        usePBKDF2 = password != null;
119        if (usePBKDF2) {
120            if (keyStoreType != null) {
121                throw new NuxeoException("Cannot use " + PROP_KEY_STORE_TYPE + " with " + PROP_PASSWORD);
122            }
123            if (keyStoreFile != null) {
124                throw new NuxeoException("Cannot use " + PROP_KEY_STORE_FILE + " with " + PROP_PASSWORD);
125            }
126            if (keyStorePassword != null) {
127                throw new NuxeoException("Cannot use " + PROP_KEY_STORE_PASSWORD + " with " + PROP_PASSWORD);
128            }
129            if (keyAlias != null) {
130                throw new NuxeoException("Cannot use " + PROP_KEY_ALIAS + " with " + PROP_PASSWORD);
131            }
132            if (keyPassword != null) {
133                throw new NuxeoException("Cannot use " + PROP_KEY_PASSWORD + " with " + PROP_PASSWORD);
134            }
135        } else {
136            if (keyStoreType == null) {
137                throw new NuxeoException("Missing " + PROP_KEY_STORE_TYPE);
138            }
139            // keystore file is optional
140            if (keyStoreFile == null && keyStorePassword != null) {
141                throw new NuxeoException("Missing " + PROP_KEY_STORE_PASSWORD);
142            }
143            if (keyAlias == null) {
144                throw new NuxeoException("Missing " + PROP_KEY_ALIAS);
145            }
146            if (keyPassword == null) {
147                keyPassword = keyStorePassword;
148            }
149        }
150        this.keyPassword = keyPassword;
151    }
152
153    protected void parseCompat() {
154        String compatKey = getProperty(PROP_COMPAT_KEY);
155        if (isBlank(compatKey)) {
156            return;
157        }
158        for (String option : compatKey.split(",")) {
159            String[] split = option.split("=", 2);
160            if (split.length != 2) {
161                log.error("Unrecognized option '" + option + "' in compatibility property '" + PROP_COMPAT_KEY + "'");
162                continue;
163            }
164            String prop = split[0];
165            String value = defaultIfBlank(split[1], null);
166            if (!Arrays.asList(PROP_PASSWORD, //
167                    PROP_KEY_STORE_TYPE, //
168                    PROP_KEY_STORE_FILE, //
169                    PROP_KEY_STORE_PASSWORD, //
170                    PROP_KEY_ALIAS, //
171                    PROP_KEY_PASSWORD, //
172                    PROP_KEY_USE_INSECURE_CIPHER).contains(prop)) {
173                log.error("Unrecognized property '" + prop + "' in compatibility property '" + PROP_COMPAT_KEY + "'");
174                continue;
175            }
176            if (properties.containsKey(prop)) {
177                log.error("Ignoring property " + option + " in compatibility property '" + PROP_COMPAT_KEY
178                        + "' because it is already present as a standard property");
179                continue;
180            }
181            properties.put(prop, value);
182        }
183    }
184
185    /**
186     * Generates an AES key from the password using PBKDF2.
187     *
188     * @param salt the salt
189     */
190    protected Key generateSecretKey(byte[] salt) throws GeneralSecurityException {
191        char[] pw = password.toCharArray();
192        SecretKeyFactory factory = SecretKeyFactory.getInstance(PBKDF2_WITH_HMAC_SHA1);
193        PBEKeySpec spec = new PBEKeySpec(pw, salt, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH);
194        Key derived = factory.generateSecret(spec);
195        spec.clearPassword();
196        return new SecretKeySpec(derived.getEncoded(), AES);
197    }
198
199    /**
200     * Gets the AES key from the keystore.
201     */
202    protected Key getSecretKey() throws GeneralSecurityException, IOException {
203        KeyStore keyStore = KeyStore.getInstance(keyStoreType);
204        char[] kspw = keyStorePassword == null ? null : keyStorePassword.toCharArray();
205        String keyStoreFile = this.keyStoreFile; // NOSONAR
206        if (Framework.isTestModeSet() && keyStoreFile != null) {
207            keyStoreFile = Framework.expandVars(keyStoreFile);
208        }
209        if (keyStoreFile == null) {
210            // some keystores are not backed by a file
211            keyStore.load(null, kspw);
212        } else {
213            try (InputStream in = new BufferedInputStream(Files.newInputStream(Paths.get(keyStoreFile)))) {
214                keyStore.load(in, kspw);
215            }
216        }
217        char[] kpw = keyPassword == null ? null : keyPassword.toCharArray();
218        return keyStore.getKey(keyAlias, kpw);
219    }
220
221    protected Cipher getCipher() throws GeneralSecurityException {
222        if (useInsecureCipher) {
223            return Cipher.getInstance(AES_CBC_PKCS5_PADDING); // NOSONAR
224        } else {
225            return Cipher.getInstance(AES_GCM_NOPADDING);
226        }
227    }
228
229    protected AlgorithmParameterSpec getParameterSpec(byte[] iv) {
230        if (useInsecureCipher) {
231            return new IvParameterSpec(iv);
232        } else {
233            return new GCMParameterSpec(128, iv);
234        }
235    }
236
237}