001/*
002 * (C) Copyright 2006-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 java.nio.charset.StandardCharsets.US_ASCII;
022import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
023
024import java.io.BufferedOutputStream;
025import java.io.DataInputStream;
026import java.io.DataOutputStream;
027import java.io.FilterInputStream;
028import java.io.FilterOutputStream;
029import java.io.IOException;
030import java.io.InputStream;
031import java.io.OutputStream;
032import java.nio.file.Files;
033import java.nio.file.NoSuchFileException;
034import java.nio.file.Path;
035import java.security.GeneralSecurityException;
036import java.security.Key;
037import java.security.SecureRandom;
038import java.util.Arrays;
039import java.util.Random;
040
041import javax.crypto.Cipher;
042import javax.crypto.CipherInputStream;
043import javax.crypto.CipherOutputStream;
044
045import org.apache.commons.io.IOUtils;
046
047/**
048 * A blob store that encrypts binaries on the filesystem using AES.
049 *
050 * @since 11.1
051 */
052public class AESBlobStore extends LocalBlobStore {
053
054    protected static final byte[] FILE_MAGIC = "NUXEOCRYPT".getBytes(US_ASCII);
055
056    protected static final int FILE_VERSION_1 = 1;
057
058    protected static final int USE_KEYSTORE = 1;
059
060    protected static final int USE_PBKDF2 = 2;
061
062    // for sanity check during reads
063    private static final int MAX_SALT_LEN = 1024;
064
065    // for sanity check during reads
066    private static final int MAX_IV_LEN = 1024;
067
068    // Random instances are thread-safe
069    protected static final Random RANDOM = new SecureRandom();
070
071    protected final AESBlobStoreConfiguration aesConfig;
072
073    public AESBlobStore(String name, KeyStrategy keyStrategy, PathStrategy pathStrategy,
074            AESBlobStoreConfiguration aesConfig) {
075        super(name, keyStrategy, pathStrategy);
076        this.aesConfig = aesConfig;
077    }
078
079    @Override
080    protected void write(BlobWriteContext blobWriteContext, Path file) throws IOException {
081        try (OutputStream out = new BufferedOutputStream(Files.newOutputStream(file));
082                EncryptingOutputStream cryptOut = new EncryptingOutputStream(out, aesConfig)) {
083            transfer(blobWriteContext, cryptOut);
084        }
085    }
086
087    @Override
088    public OptionalOrUnknown<Path> getFile(String key) {
089        return OptionalOrUnknown.unknown();
090    }
091
092    @SuppressWarnings("resource")
093    @Override
094    public OptionalOrUnknown<InputStream> getStream(String key) throws IOException {
095        OptionalOrUnknown<InputStream> streamOpt = super.getStream(key);
096        if (!streamOpt.isPresent()) {
097            return streamOpt;
098        }
099        try {
100            InputStream in = streamOpt.get();
101            try {
102                return OptionalOrUnknown.of(new DecryptingInputStream(in, aesConfig));
103            } catch (IOException e) {
104                in.close();
105                throw e;
106            }
107        } catch (NoSuchFileException e) {
108            return OptionalOrUnknown.missing();
109        }
110    }
111
112    @Override
113    public boolean readBlob(String key, Path dest) throws IOException {
114        OptionalOrUnknown<InputStream> streamOpt = getStream(key);
115        if (streamOpt.isPresent()) {
116            try (InputStream stream = streamOpt.get()) {
117                Files.copy(stream, dest, REPLACE_EXISTING);
118            }
119            return true;
120        } else if (streamOpt.isMissing()) {
121            return false;
122        } else {
123            // this implementation never returns Maybe.unknown()
124            throw new IllegalStateException("stream should always be known");
125        }
126    }
127
128    @Override
129    public boolean copyBlobIsOptimized(BlobStore sourceStore) {
130        return false;
131    }
132
133    @Override
134    public boolean copyBlob(String key, BlobStore sourceStore, String sourceKey, boolean atomicMove)
135            throws IOException {
136        throw new UnsupportedOperationException();
137    }
138
139    /**
140     * Output stream that encrypts while writing.
141     * <p>
142     * Stream format version 1 (values are in network order):
143     * <ul>
144     * <li>10 bytes: magic number "NUXEOCRYPT"
145     * <li>1 byte: file format version = 1
146     * <li>1 byte: use keystore = 1, use PBKDF2 = 2
147     * <li>if use PBKDF2:
148     * <ul>
149     * <li>4 bytes: salt length = n
150     * <li>n bytes: salt data
151     * </ul>
152     * <li>4 bytes: IV length = p
153     * <li>p bytes: IV data
154     * <li>x bytes: encrypted stream
155     * </ul>
156     *
157     * @see DecryptingInputStream
158     */
159    public static class EncryptingOutputStream extends FilterOutputStream {
160
161        protected final AESBlobStoreConfiguration aesConfig;
162
163        public EncryptingOutputStream(OutputStream out, AESBlobStoreConfiguration aesConfig) throws IOException {
164            super(out);
165            this.aesConfig = aesConfig;
166            writeHeader();
167        }
168
169        protected void writeHeader() throws IOException {
170            // write magic + version
171            out.write(FILE_MAGIC);
172            DataOutputStream data = new DataOutputStream(out);
173            data.writeByte(FILE_VERSION_1);
174
175            Cipher cipher;
176            try {
177                // secret key
178                Key secret;
179                if (aesConfig.usePBKDF2) {
180                    data.writeByte(USE_PBKDF2);
181                    // generate a salt
182                    byte[] salt = new byte[16];
183                    RANDOM.nextBytes(salt);
184                    // generate secret key
185                    secret = aesConfig.generateSecretKey(salt);
186                    // write salt
187                    data.writeInt(salt.length);
188                    data.write(salt);
189                } else {
190                    data.writeByte(USE_KEYSTORE);
191                    // find secret key from keystore
192                    secret = aesConfig.getSecretKey();
193                }
194
195                // cipher
196                cipher = aesConfig.getCipher();
197                cipher.init(Cipher.ENCRYPT_MODE, secret);
198
199                // write IV
200                byte[] iv = cipher.getIV();
201                data.writeInt(iv.length);
202                data.write(iv);
203                data.flush();
204            } catch (GeneralSecurityException e) {
205                throw new IOException(e);
206            }
207
208            // now replace the output stream with the ciphering version
209            out = new CipherOutputStream(out, cipher);
210        }
211
212        // we don't just delegate to write(int) as it's inefficient (squid:S4349)
213        @Override
214        public void write(byte[] b, int off, int len) throws IOException {
215            out.write(b, off, len);
216        }
217    }
218
219    /**
220     * Input stream that decrypts while reading.
221     * <p>
222     * See {@link EncryptingOutputStream} for the stream format.
223     *
224     * @see EncryptingOutputStream
225     */
226    public static class DecryptingInputStream extends FilterInputStream {
227
228        protected final AESBlobStoreConfiguration aesConfig;
229
230        public DecryptingInputStream(InputStream in, AESBlobStoreConfiguration aesConfig) throws IOException {
231            super(in);
232            this.aesConfig = aesConfig;
233            readHeader();
234        }
235
236        protected void readHeader() throws IOException {
237            // read magic
238            byte[] magic = new byte[FILE_MAGIC.length];
239            IOUtils.read(in, magic);
240            if (!Arrays.equals(magic, FILE_MAGIC)) {
241                throw new IOException("Invalid file (bad magic)");
242            }
243            // read version
244            DataInputStream data = new DataInputStream(in);
245            byte magicvers = data.readByte();
246            if (magicvers != FILE_VERSION_1) {
247                throw new IOException("Invalid file (bad version)");
248            }
249
250            // check use
251            byte usepb = data.readByte();
252            if (usepb == USE_PBKDF2) {
253                if (!aesConfig.usePBKDF2) {
254                    throw new IOException("File requires PBKDF2 password");
255                }
256            } else if (usepb == USE_KEYSTORE) {
257                if (aesConfig.usePBKDF2) {
258                    throw new IOException("File requires keystore");
259                }
260            } else {
261                throw new IOException("Invalid file (bad use)");
262            }
263
264            Cipher cipher;
265            try {
266                // secret key
267                Key secret;
268                if (aesConfig.usePBKDF2) {
269                    // read salt first
270                    int saltLen = data.readInt();
271                    if (saltLen <= 0 || saltLen > MAX_SALT_LEN) {
272                        throw new IOException("Invalid salt length: " + saltLen);
273                    }
274                    byte[] salt = new byte[saltLen];
275                    data.read(salt, 0, saltLen);
276                    secret = aesConfig.generateSecretKey(salt);
277                } else {
278                    secret = aesConfig.getSecretKey();
279                }
280
281                // read IV
282                int ivLen = data.readInt();
283                if (ivLen <= 0 || ivLen > MAX_IV_LEN) {
284                    throw new IOException("Invalid IV length: " + ivLen);
285                }
286                byte[] iv = new byte[ivLen];
287                data.read(iv, 0, ivLen);
288
289                // cipher
290                cipher = aesConfig.getCipher();
291                cipher.init(Cipher.DECRYPT_MODE, secret, aesConfig.getParameterSpec(iv));
292            } catch (GeneralSecurityException e) {
293                throw new IOException(e);
294            }
295
296            // now replace the input stream with the deciphering version
297            in = new CipherInputStream(in, cipher);
298        }
299    }
300
301}