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}