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}