/*
* (C) Copyright 2006-2014 Nuxeo SA (http://nuxeo.com/) and others.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* Contributors:
* Florent Guillaume
*/
package org.nuxeo.ecm.core.blob.binary;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.FilterOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.reflect.Field;
import java.security.GeneralSecurityException;
import java.security.Key;
import java.security.KeyStore;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Map;
import java.util.Random;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.CipherInputStream;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.nuxeo.ecm.core.api.NuxeoException;
import org.nuxeo.runtime.api.Framework;
/**
* A binary manager that encrypts binaries on the filesystem using AES.
* <p>
* The configuration holds the keystore information to retrieve the AES key, or the
* password that is used to generate a per-file key using PBKDF2. This configuration comes from the
* {@code <property name="key">...</property>} of the binary manager configuration.
* <p>
* The configuration has the form {@code key1=value1,key2=value2,...} where the possible keys are, for keystore use:
* <ul>
* <li>keyStoreType: the keystore type, for instance JCEKS
* <li>keyStoreFile: the path to the keystore, if applicable
* <li>keyStorePassword: the keystore password
* <li>keyAlias: the alias (name) of the key in the keystore
* <li>keyPassword: the key password
* </ul>
* <p>
* And for PBKDF2 use:
* <ul>
* <li>password: the password
* </ul>
* <p>
* To encrypt a binary, an AES key is needed. This key can be retrieved from a keystore, or generated from a password
* using PBKDF2 (in which case each stored file contains a different salt for security reasons). The file format is
* described in {@link #storeAndDigest(InputStream, OutputStream)}.
* <p>
* While the binary is being used by the application, a temporarily-decrypted file is held in a temporary directory. It
* is removed as soon as possible.
* <p>
* Note: if the Java Cryptographic Extension (JCE) is not configured for 256-bit key length, you may get an exception
* "java.security.InvalidKeyException: Illegal key size or default parameters". If this is the case, go to <a
* href="http://www.oracle.com/technetwork/java/javase/downloads/index.html" >Oracle Java SE Downloads</a> and download
* and install the Java Cryptography Extension (JCE) Unlimited Strength Jurisdiction Policy Files for your JDK.
*
* @since 6.0
*/
public class AESBinaryManager extends LocalBinaryManager {
private static final Log log = LogFactory.getLog(AESBinaryManager.class);
protected static final byte[] FILE_MAGIC = new byte[] { 'N', 'U', 'X', 'E', 'O', 'C', 'R', 'Y', 'P', 'T' };
protected static final int FILE_VERSION_1 = 1;
protected static final int USE_KEYSTORE = 1;
protected static final int USE_PBKDF2 = 2;
protected static final String AES = "AES";
protected static final String AES_CBC_PKCS5_PADDING = "AES/CBC/PKCS5Padding";
protected static final String PBKDF2_WITH_HMAC_SHA1 = "PBKDF2WithHmacSHA1";
protected static final int PBKDF2_ITERATIONS = 10000;
// AES-256
protected static final int PBKDF2_KEY_LENGTH = 256;
protected static final String PARAM_PASSWORD = "password";
protected static final String PARAM_KEY_STORE_TYPE = "keyStoreType";
protected static final String PARAM_KEY_STORE_FILE = "keyStoreFile";
protected static final String PARAM_KEY_STORE_PASSWORD = "keyStorePassword";
protected static final String PARAM_KEY_ALIAS = "keyAlias";
protected static final String PARAM_KEY_PASSWORD = "keyPassword";
// for sanity check during reads
private static final int MAX_SALT_LEN = 1024;
// for sanity check during reads
private static final int MAX_IV_LEN = 1024;
// Random instances are thread-safe
protected static final Random RANDOM = new SecureRandom();
// the digest from the root descriptor
protected String digestAlgorithm;
protected boolean usePBKDF2;
protected String password;
protected String keyStoreType;
protected String keyStoreFile;
protected String keyStorePassword;
protected String keyAlias;
protected String keyPassword;
public AESBinaryManager() {
setUnlimitedJCEPolicy();
}
/**
* By default the JRE may ship with restricted key length. Instead of having administrators download the Java
* Cryptography Extension (JCE) Unlimited Strength Jurisdiction Policy Files from
* http://www.oracle.com/technetwork/java/javase/downloads/index.html, we attempt to directly unrestrict the JCE
* using reflection.
* <p>
* This is not possible anymore since 8u102 and https://bugs.openjdk.java.net/browse/JDK-8149417
*/
protected static boolean setUnlimitedJCEPolicy() {
try {
Field field = Class.forName("javax.crypto.JceSecurity").getDeclaredField("isRestricted");
field.setAccessible(true);
if (Boolean.TRUE.equals(field.get(null))) {
log.info("Setting JCE Unlimited Strength");
field.set(null, Boolean.FALSE);
}
return true;
} catch (ReflectiveOperationException | SecurityException | IllegalArgumentException e) {
log.debug("Cannot check/set JCE Unlimited Strength", e);
return false;
}
}
@Override
public void initialize(String blobProviderId, Map<String, String> properties) throws IOException {
super.initialize(blobProviderId, properties);
digestAlgorithm = getDigestAlgorithm();
String options = properties.get(BinaryManager.PROP_KEY);
// TODO parse options from properties directly
if (StringUtils.isBlank(options)) {
throw new NuxeoException("Missing key for " + getClass().getSimpleName());
}
initializeOptions(options);
}
protected void initializeOptions(String options) {
for (String option : options.split(",")) {
String[] split = option.split("=", 2);
if (split.length != 2) {
throw new NuxeoException("Unrecognized option: " + option);
}
String value = StringUtils.defaultIfBlank(split[1], null);
switch (split[0]) {
case PARAM_PASSWORD:
password = value;
break;
case PARAM_KEY_STORE_TYPE:
keyStoreType = value;
break;
case PARAM_KEY_STORE_FILE:
keyStoreFile = value;
break;
case PARAM_KEY_STORE_PASSWORD:
keyStorePassword = value;
break;
case PARAM_KEY_ALIAS:
keyAlias = value;
break;
case PARAM_KEY_PASSWORD:
keyPassword = value;
break;
default:
throw new NuxeoException("Unrecognized option: " + option);
}
}
usePBKDF2 = password != null;
if (usePBKDF2) {
if (keyStoreType != null) {
throw new NuxeoException("Cannot use " + PARAM_KEY_STORE_TYPE + " with " + PARAM_PASSWORD);
}
if (keyStoreFile != null) {
throw new NuxeoException("Cannot use " + PARAM_KEY_STORE_FILE + " with " + PARAM_PASSWORD);
}
if (keyStorePassword != null) {
throw new NuxeoException("Cannot use " + PARAM_KEY_STORE_PASSWORD + " with " + PARAM_PASSWORD);
}
if (keyAlias != null) {
throw new NuxeoException("Cannot use " + PARAM_KEY_ALIAS + " with " + PARAM_PASSWORD);
}
if (keyPassword != null) {
throw new NuxeoException("Cannot use " + PARAM_KEY_PASSWORD + " with " + PARAM_PASSWORD);
}
} else {
if (keyStoreType == null) {
throw new NuxeoException("Missing " + PARAM_KEY_STORE_TYPE);
}
// keystore file is optional
if (keyStoreFile == null && keyStorePassword != null) {
throw new NuxeoException("Missing " + PARAM_KEY_STORE_PASSWORD);
}
if (keyAlias == null) {
throw new NuxeoException("Missing " + PARAM_KEY_ALIAS);
}
if (keyPassword == null) {
keyPassword = keyStorePassword;
}
}
}
/**
* Gets the password for PBKDF2.
* <p>
* The caller must clear it from memory when done with it by calling {@link #clearPassword}.
*/
protected char[] getPassword() {
return password.toCharArray();
}
/**
* Clears a password from memory.
*/
protected void clearPassword(char[] password) {
if (password != null) {
Arrays.fill(password, '\0');
}
}
/**
* Generates an AES key from the password using PBKDF2.
*
* @param salt the salt
*/
protected Key generateSecretKey(byte[] salt) throws GeneralSecurityException {
char[] password = getPassword();
SecretKeyFactory factory = SecretKeyFactory.getInstance(PBKDF2_WITH_HMAC_SHA1);
PBEKeySpec spec = new PBEKeySpec(password, salt, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH);
clearPassword(password);
Key derived = factory.generateSecret(spec);
spec.clearPassword();
return new SecretKeySpec(derived.getEncoded(), AES);
}
/**
* Gets the AES key from the keystore.
*/
protected Key getSecretKey() throws GeneralSecurityException, IOException {
KeyStore keyStore = KeyStore.getInstance(keyStoreType);
char[] kspw = keyStorePassword == null ? null : keyStorePassword.toCharArray();
if (keyStoreFile != null) {
try (InputStream in = new BufferedInputStream(new FileInputStream(keyStoreFile))) {
keyStore.load(in, kspw);
}
} else {
// some keystores are not backed by a file
keyStore.load(null, kspw);
}
clearPassword(kspw);
char[] kpw = keyPassword == null ? null : keyPassword.toCharArray();
Key key = keyStore.getKey(keyAlias, kpw);
clearPassword(kpw);
return key;
}
@Override
protected Binary getBinary(InputStream in) throws IOException {
// write to a tmp file that will be used by the returned Binary
// TODO if stream source, avoid copy (no-copy optimization)
File tmp = File.createTempFile("bin_", ".tmp", tmpDir);
Framework.trackFile(tmp, tmp);
OutputStream out = new BufferedOutputStream(new FileOutputStream(tmp));
IOUtils.copy(in, out);
in.close();
out.close();
// encrypt an digest into final file
InputStream nin = new BufferedInputStream(new FileInputStream(tmp));
String digest = storeAndDigest(nin); // calls our storeAndDigest
// return a binary on our tmp file
return new Binary(tmp, digest, blobProviderId);
}
@Override
public Binary getBinary(String digest) {
File file = getFileForDigest(digest, false);
if (file == null) {
log.warn("Invalid digest format: " + digest);
return null;
}
if (!file.exists()) {
return null;
}
File tmp;
try {
tmp = File.createTempFile("bin_", ".tmp", tmpDir);
Framework.trackFile(tmp, tmp);
OutputStream out = new BufferedOutputStream(new FileOutputStream(tmp));
InputStream in = new BufferedInputStream(new FileInputStream(file));
try {
decrypt(in, out);
} finally {
in.close();
out.close();
}
} catch (IOException e) {
throw new RuntimeException(e);
}
// return a binary on our tmp file
return new Binary(tmp, digest, blobProviderId);
}
@Override
protected String storeAndDigest(InputStream in) throws IOException {
File tmp = File.createTempFile("create_", ".tmp", tmpDir);
OutputStream out = new BufferedOutputStream(new FileOutputStream(tmp));
/*
* First, write the input stream to a temporary file, while computing a digest.
*/
try {
String digest;
try {
digest = storeAndDigest(in, out);
} finally {
in.close();
out.close();
}
/*
* Move the tmp file to its destination.
*/
File file = getFileForDigest(digest, true);
atomicMove(tmp, file);
return digest;
} finally {
tmp.delete();
}
}
/**
* Encrypts the given input stream into the given output stream, while also computing the digest of the input
* stream.
* <p>
* File format version 1 (values are in network order):
* <ul>
* <li>10 bytes: magic number "NUXEOCRYPT"
* <li>1 byte: file format version = 1
* <li>1 byte: use keystore = 1, use PBKDF2 = 2
* <li>if use PBKDF2:
* <ul>
* <li>4 bytes: salt length = n
* <li>n bytes: salt data
* </ul>
* <li>4 bytes: IV length = p
* <li>p bytes: IV data
* <li>x bytes: encrypted stream
* </ul>
*
* @param in the input stream containing the data
* @param file the file containing the encrypted data
* @return the digest of the input stream
*/
@Override
public String storeAndDigest(InputStream in, OutputStream out) throws IOException {
out.write(FILE_MAGIC);
DataOutputStream data = new DataOutputStream(out);
data.writeByte(FILE_VERSION_1);
try {
// get digest to use
MessageDigest messageDigest = MessageDigest.getInstance(digestAlgorithm);
// secret key
Key secret;
if (usePBKDF2) {
data.writeByte(USE_PBKDF2);
// generate a salt
byte[] salt = new byte[16];
RANDOM.nextBytes(salt);
// generate secret key
secret = generateSecretKey(salt);
// write salt
data.writeInt(salt.length);
data.write(salt);
} else {
data.writeByte(USE_KEYSTORE);
// find secret key from keystore
secret = getSecretKey();
}
// cipher
Cipher cipher = Cipher.getInstance(AES_CBC_PKCS5_PADDING);
cipher.init(Cipher.ENCRYPT_MODE, secret);
// write IV
byte[] iv = cipher.getIV();
data.writeInt(iv.length);
data.write(iv);
// digest and write the encrypted data
CipherAndDigestOutputStream cipherOut = new CipherAndDigestOutputStream(out, cipher, messageDigest);
IOUtils.copy(in, cipherOut);
cipherOut.close();
byte[] digest = cipherOut.getDigest();
return toHexString(digest);
} catch (GeneralSecurityException e) {
throw new NuxeoException(e);
}
}
/**
* Decrypts the given input stream into the given output stream.
*/
protected void decrypt(InputStream in, OutputStream out) throws IOException {
byte[] magic = new byte[FILE_MAGIC.length];
IOUtils.read(in, magic);
if (!Arrays.equals(magic, FILE_MAGIC)) {
throw new IOException("Invalid file (bad magic)");
}
DataInputStream data = new DataInputStream(in);
byte magicvers = data.readByte();
if (magicvers != FILE_VERSION_1) {
throw new IOException("Invalid file (bad version)");
}
byte usepb = data.readByte();
if (usepb == USE_PBKDF2) {
if (!usePBKDF2) {
throw new NuxeoException("File requires PBKDF2 password");
}
} else if (usepb == USE_KEYSTORE) {
if (usePBKDF2) {
throw new NuxeoException("File requires keystore");
}
} else {
throw new IOException("Invalid file (bad use)");
}
try {
// secret key
Key secret;
if (usePBKDF2) {
// read salt first
int saltLen = data.readInt();
if (saltLen <= 0 || saltLen > MAX_SALT_LEN) {
throw new NuxeoException("Invalid salt length: " + saltLen);
}
byte[] salt = new byte[saltLen];
data.read(salt, 0, saltLen);
secret = generateSecretKey(salt);
} else {
secret = getSecretKey();
}
// read IV
int ivLen = data.readInt();
if (ivLen <= 0 || ivLen > MAX_IV_LEN) {
throw new NuxeoException("Invalid IV length: " + ivLen);
}
byte[] iv = new byte[ivLen];
data.read(iv, 0, ivLen);
// cipher
Cipher cipher;
cipher = Cipher.getInstance(AES_CBC_PKCS5_PADDING);
cipher.init(Cipher.DECRYPT_MODE, secret, new IvParameterSpec(iv));
// read the encrypted data
try (InputStream cipherIn = new CipherInputStream(in, cipher)) {
IOUtils.copy(cipherIn, out);
} catch (IOException e) {
Throwable cause = e.getCause();
if (cause != null && cause instanceof BadPaddingException) {
throw new NuxeoException(cause.getMessage(), e);
}
}
} catch (GeneralSecurityException e) {
throw new NuxeoException(e);
}
}
/**
* A {@link javax.crypto.CipherOutputStream CipherOutputStream} that also does a digest of the original stream at
* the same time.
*/
public static class CipherAndDigestOutputStream extends FilterOutputStream {
protected Cipher cipher;
protected OutputStream out;
protected MessageDigest messageDigest;
protected byte[] digest;
public CipherAndDigestOutputStream(OutputStream out, Cipher cipher, MessageDigest messageDigest) {
super(out);
this.out = out;
this.cipher = cipher;
this.messageDigest = messageDigest;
}
public byte[] getDigest() {
return digest;
}
@Override
public void write(int b) throws IOException {
write(new byte[] { (byte) b }, 0, 1);
}
@Override
public void write(byte b[], int off, int len) throws IOException {
messageDigest.update(b, off, len);
byte[] bytes = cipher.update(b, off, len);
if (bytes != null) {
out.write(bytes);
bytes = null; // help GC
}
}
@Override
public void flush() throws IOException {
out.flush();
}
@Override
public void close() throws IOException {
digest = messageDigest.digest();
try {
byte[] bytes = cipher.doFinal();
out.write(bytes);
bytes = null; // help GC
} catch (GeneralSecurityException e) {
throw new NuxeoException(e);
}
try {
flush();
} finally {
out.close();
}
}
}
}