/**
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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.
 */
package org.apache.camel.converter.crypto;

import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.security.Key;
import java.security.spec.AlgorithmParameterSpec;

import javax.crypto.Cipher;
import javax.crypto.CipherInputStream;
import javax.crypto.CipherOutputStream;
import javax.crypto.spec.IvParameterSpec;

import static javax.crypto.Cipher.DECRYPT_MODE;
import static javax.crypto.Cipher.ENCRYPT_MODE;

import org.apache.camel.Exchange;
import org.apache.camel.spi.DataFormat;
import org.apache.camel.util.ExchangeHelper;
import org.apache.camel.util.IOHelper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * <code>CryptoDataFormat</code> uses a specified key and algorithm to encrypt,
 * decrypt and verify exchange payloads. The Data format allows an
 * initialization vector to be supplied. The use of this initialization vector
 * or IV is different depending on the algorithm type block or streaming, but it
 * is desirable to be able to control it. Also in certain cases it may be
 * necessary to have access to the IV in the decryption phase and as the IV
 * doens't necessarily need to be kept secret it is ok to inline this in the
 * stream and read it out on the other side prior to decryption. For more
 * information on Initialization vectors see
 * <ul>
 * <li>http://en.wikipedia.org/wiki/Initialization_vector</li>
 * <li>http://www.herongyang.com/Cryptography/</li>
 * <li>http://en.wikipedia.org/wiki/Block_cipher_modes_of_operation</li>
 * <ul>
 * <p/>
 * To avoid attacks against the encrypted data while it is in transit the
 * {@link CryptoDataFormat} can also calculate a Message Authentication Code for
 * the encrypted exchange contents based on a configurable MAC algorithm. The
 * calculated HMAC is appended to the stream after encryption. It is separated
 * from the stream in the decryption phase. The MAC is recalculated and verified
 * against the transmitted version to insure nothing was tampered with in
 * transit.For more information on Message Authentication Codes see
 * <ul>
 * <li>http://en.wikipedia.org/wiki/HMAC</li>
 * </ul>
 */
public class CryptoDataFormat implements DataFormat {

    public static final String KEY = "CamelCryptoKey";

    private static final Logger LOG = LoggerFactory.getLogger(CryptoDataFormat.class);
    private static final String INIT_VECTOR = "CamelCryptoInitVector";
    private String algorithm = "DES/CBC/PKCS5Padding";
    private String cryptoProvider;
    private Key configuredkey;
    private int bufferSize = 4096;
    private byte[] initializationVector;
    private boolean inline;
    private String macAlgorithm = "HmacSHA1";
    private boolean shouldAppendHMAC;
    private AlgorithmParameterSpec parameterSpec;

    public CryptoDataFormat() {
    }

    public CryptoDataFormat(String algorithm, Key key) {
        this(algorithm, key, null);
    }

    public CryptoDataFormat(String algorithm, Key key, String cryptoProvider) {
        this.algorithm = algorithm;
        this.configuredkey = key;
        this.cryptoProvider = cryptoProvider;
    }

    private Cipher initializeCipher(int mode, Key key, byte[] iv) throws Exception {
        Cipher cipher = cryptoProvider == null ? Cipher.getInstance(algorithm) : Cipher.getInstance(algorithm, cryptoProvider);

        if (key == null) {
            throw new IllegalStateException("A valid encryption key is required. Either configure the CryptoDataFormat "
                    + "with a key or provide one in a header using the header name 'CamelCryptoKey'");
        }

        if (mode == ENCRYPT_MODE || mode == DECRYPT_MODE) {
            if (iv != null) {
                cipher.init(mode, key, new IvParameterSpec(iv));
            } else if (parameterSpec != null) {
                cipher.init(mode, key, parameterSpec);
            } else {
                cipher.init(mode, key);
            }
        }
        return cipher;
    }

    public void marshal(Exchange exchange, Object graph, OutputStream outputStream) throws Exception {
        byte[] iv = getInitializationVector(exchange);
        Key key = getKey(exchange);

        CipherOutputStream cipherStream = new CipherOutputStream(outputStream, initializeCipher(ENCRYPT_MODE, key, iv));
        InputStream plaintextStream = ExchangeHelper.convertToMandatoryType(exchange, InputStream.class, graph);
        HMACAccumulator hmac = getMessageAuthenticationCode(key);
        if (plaintextStream != null) {
            inlineInitVector(outputStream, iv);
            byte[] buffer = new byte[bufferSize];
            int read;
            try {
                while ((read = plaintextStream.read(buffer)) > 0) {
                    cipherStream.write(buffer, 0, read);
                    cipherStream.flush();
                    hmac.encryptUpdate(buffer, read);
                }
                // only write if there is data to write (IBM JDK throws exception if no data)
                byte[] mac = hmac.getCalculatedMac();
                if (mac != null && mac.length > 0) {
                    cipherStream.write(mac);
                }
            } finally {
                IOHelper.close(cipherStream, "cipher", LOG);
                IOHelper.close(plaintextStream, "plaintext", LOG);
            }
        }
    }

    public Object unmarshal(Exchange exchange, InputStream encryptedStream) throws Exception {
        Object unmarshalled = null;
        if (encryptedStream != null) {
            byte[] iv = getInlinedInitializationVector(exchange, encryptedStream);
            Key key = getKey(exchange);
            CipherInputStream cipherStream = new CipherInputStream(encryptedStream, initializeCipher(DECRYPT_MODE, key, iv));

            ByteArrayOutputStream plaintextStream = new ByteArrayOutputStream(bufferSize);
            HMACAccumulator hmac = getMessageAuthenticationCode(key);
            byte[] buffer = new byte[bufferSize];
            hmac.attachStream(plaintextStream);
            int read;
            while ((read = cipherStream.read(buffer)) >= 0) {
                hmac.decryptUpdate(buffer, read);
            }
            hmac.validate();
            unmarshalled = plaintextStream.toByteArray();
        }
        return unmarshalled;
    }

    private void inlineInitVector(OutputStream outputStream, byte[] iv) throws IOException {
        if (inline) {
            DataOutputStream dout = new DataOutputStream(outputStream);
            dout.writeInt(iv.length);
            outputStream.write(iv);
            outputStream.flush();
        }
    }

    private byte[] getInlinedInitializationVector(Exchange exchange, InputStream encryptedStream) throws IOException {
        byte[] iv = getInitializationVector(exchange);
        if (inline) {
            try {
                int ivLength = new DataInputStream(encryptedStream).readInt();
                iv = new byte[ivLength];
                int read = encryptedStream.read(iv);
                if (read != ivLength) {
                    throw new IOException(String.format("Attempted to read a '%d' byte initialization vector from inputStream but only"
                            + " '%d' bytes were retrieved", ivLength, read));
                }
            } catch (IOException e) {
                throw new IOException("Error reading initialization vector from encrypted stream", e);
            }
        }
        return iv;
    }

    private HMACAccumulator getMessageAuthenticationCode(Key key) throws Exception {
        // return an actual Hmac Calculator or a 'Null' noop version.
        return shouldAppendHMAC ? new HMACAccumulator(key, macAlgorithm, cryptoProvider, bufferSize) : new HMACAccumulator() {
            byte[] empty = new byte[0];

            public void encryptUpdate(byte[] buffer, int read) {
            }

            public void decryptUpdate(byte[] buffer, int read) throws IOException {
                outputStream.write(buffer, 0, read);
            }

            public void validate() {
            }

            public byte[] getCalculatedMac() {
                return empty;
            }
        };
    }

    private byte[] getInitializationVector(Exchange exchange) {
        byte[] iv = exchange.getIn().getHeader(INIT_VECTOR, byte[].class);
        if (iv == null) {
            iv = initializationVector;
        }
        return iv;
    }

    private Key getKey(Exchange exchange) {
        Key key = exchange.getIn().getHeader(KEY, Key.class);
        if (key != null) {
            exchange.getIn().setHeader(KEY, null);
        } else {
            key = configuredkey;
        }
        return key;
    }

    public void setInitializationVector(byte[] initializationVector) {
        if (initializationVector != null) {
            this.initializationVector = initializationVector;
        }
    }

    /**
     * Meant for use with a Symmetric block Cipher and specifies that the
     * initialization vector should be written to the cipher stream ahead of the
     * encrypted ciphertext. When the payload is to be decrypted this
     * initialization vector will need to be read from the stream. Requires that
     * the formatter has been configured with an init vector that is valid for
     * the give algorithm.
     *
     * @param inline true if the initialization vector should be inlined in the stream.
     */
    public void setShouldInlineInitializationVector(boolean inline) {
        this.inline = inline;
    }

    /**
     * Sets the JCE name of the Encryption Algorithm that should be used
     */
    public void setAlgorithm(String algorithm) {
        this.algorithm = algorithm;
    }

    /**
     * Sets a custom {@link AlgorithmParameterSpec} that should be used to
     * configure the Cipher. Note that if an Initalization vector is provided
     * then the IvParameterSpec will be used and any value set here will be
     * ignored
     */
    public void setAlgorithmParameterSpec(AlgorithmParameterSpec parameterSpec) {
        this.parameterSpec = parameterSpec;
    }

    /**
     * Sets the name of the JCE provider e.g. SUN or BC for Bouncy
     */
    public void setCryptoProvider(String cryptoProvider) {
        this.cryptoProvider = cryptoProvider;
    }

    /**
     * Sets the algorithm used to create the Hash-based Message Authentication
     * Code (HMAC) appended to the stream.
     */
    public void setMacAlgorithm(String macAlgorithm) {
        this.macAlgorithm = macAlgorithm;
    }

    /**
     * Whether a Hash-based Message Authentication Code (HMAC) should be
     * calculated and appended to the stream.
     */
    public void setShouldAppendHMAC(boolean shouldAppendHMAC) {
        this.shouldAppendHMAC = shouldAppendHMAC;
    }

    /**
     * Set the key that should be used to encrypt or decrypt incoming encrypted exchanges.
     */
    public void setKey(Key key) {
        this.configuredkey = key;
    }

    /**
     * Set the size of the buffer used to
     */
    public void setBufferSize(int bufferSize) {
        this.bufferSize = bufferSize;
    }
}
