package org.bouncycastle.crypto.modes;

import java.io.ByteArrayOutputStream;

import org.bouncycastle.crypto.BlockCipher;
import org.bouncycastle.crypto.CipherParameters;
import org.bouncycastle.crypto.DataLengthException;
import org.bouncycastle.crypto.InvalidCipherTextException;
import org.bouncycastle.crypto.Mac;
import org.bouncycastle.crypto.OutputLengthException;
import org.bouncycastle.crypto.macs.CBCBlockCipherMac;
import org.bouncycastle.crypto.params.AEADParameters;
import org.bouncycastle.crypto.params.ParametersWithIV;
import org.bouncycastle.util.Arrays;

/**
 * Implements the Counter with Cipher Block Chaining mode (CCM) detailed in
 * NIST Special Publication 800-38C.
 * <p>
 * <b>Note</b>: this mode is a packet mode - it needs all the data up front.
 */
public class CCMBlockCipher
    implements AEADBlockCipher
{
    private BlockCipher           cipher;
    private int                   blockSize;
    private boolean               forEncryption;
    private byte[]                nonce;
    private byte[]                initialAssociatedText;
    private int                   macSize;
    private CipherParameters      keyParam;
    private byte[]                macBlock;
    private ExposedByteArrayOutputStream associatedText = new ExposedByteArrayOutputStream();
    private ExposedByteArrayOutputStream data = new ExposedByteArrayOutputStream();

    /**
     * Basic constructor.
     *
     * @param c the block cipher to be used.
     */
    public CCMBlockCipher(BlockCipher c)
    {
        this.cipher = c;
        this.blockSize = c.getBlockSize();
        this.macBlock = new byte[blockSize];

        if (blockSize != 16)
        {
            throw new IllegalArgumentException("cipher required with a block size of 16.");
        }
    }

    /**
     * return the underlying block cipher that we are wrapping.
     *
     * @return the underlying block cipher that we are wrapping.
     */
    public BlockCipher getUnderlyingCipher()
    {
        return cipher;
    }


    public void init(boolean forEncryption, CipherParameters params)
          throws IllegalArgumentException
    {
        this.forEncryption = forEncryption;

        CipherParameters cipherParameters;
        if (params instanceof AEADParameters)
        {
            AEADParameters param = (AEADParameters)params;

            nonce = param.getNonce();
            initialAssociatedText = param.getAssociatedText();
            macSize = param.getMacSize() / 8;
            cipherParameters = param.getKey();
        }
        else if (params instanceof ParametersWithIV)
        {
            ParametersWithIV param = (ParametersWithIV)params;

            nonce = param.getIV();
            initialAssociatedText = null;
            macSize = macBlock.length / 2;
            cipherParameters = param.getParameters();
        }
        else
        {
            throw new IllegalArgumentException("invalid parameters passed to CCM: " + params.getClass().getName());
        }

        // NOTE: Very basic support for key re-use, but no performance gain from it
        if (cipherParameters != null)
        {
            keyParam = cipherParameters;
        }

        if (nonce == null || nonce.length < 7 || nonce.length > 13)
        {
            throw new IllegalArgumentException("nonce must have length from 7 to 13 octets");
        }

        reset();
    }

    public String getAlgorithmName()
    {
        return cipher.getAlgorithmName() + "/CCM";
    }

    public void processAADByte(byte in)
    {
        associatedText.write(in);
    }

    public void processAADBytes(byte[] in, int inOff, int len)
    {
        // TODO: Process AAD online
        associatedText.write(in, inOff, len);
    }

    public int processByte(byte in, byte[] out, int outOff)
        throws DataLengthException, IllegalStateException
    {
        data.write(in);

        return 0;
    }

    public int processBytes(byte[] in, int inOff, int inLen, byte[] out, int outOff)
        throws DataLengthException, IllegalStateException
    {
        if (in.length < (inOff + inLen))
        {
            throw new DataLengthException("Input buffer too short");
        }
        data.write(in, inOff, inLen);

        return 0;
    }

    public int doFinal(byte[] out, int outOff)
        throws IllegalStateException, InvalidCipherTextException
    {
        int len = processPacket(data.getBuffer(), 0, data.size(), out, outOff);

        reset();

        return len;
    }

    public void reset()
    {
        cipher.reset();
        associatedText.reset();
        data.reset();
    }

    /**
     * Returns a byte array containing the mac calculated as part of the
     * last encrypt or decrypt operation.
     *
     * @return the last mac calculated.
     */
    public byte[] getMac()
    {
        byte[] mac = new byte[macSize];

        System.arraycopy(macBlock, 0, mac, 0, mac.length);

        return mac;
    }

    public int getUpdateOutputSize(int len)
    {
        return 0;
    }

    public int getOutputSize(int len)
    {
        int totalData = len + data.size();

        if (forEncryption)
        {
             return totalData + macSize;
        }

        return totalData < macSize ? 0 : totalData - macSize;
    }

    /**
     * Process a packet of data for either CCM decryption or encryption.
     *
     * @param in data for processing.
     * @param inOff offset at which data starts in the input array.
     * @param inLen length of the data in the input array.
     * @return a byte array containing the processed input..
     * @throws IllegalStateException if the cipher is not appropriately set up.
     * @throws InvalidCipherTextException if the input data is truncated or the mac check fails.
     */
    public byte[] processPacket(byte[] in, int inOff, int inLen)
        throws IllegalStateException, InvalidCipherTextException
    {
        byte[] output;

        if (forEncryption)
        {
            output = new byte[inLen + macSize];
        }
        else
        {
            if (inLen < macSize)
            {
                throw new InvalidCipherTextException("data too short");
            }
            output = new byte[inLen - macSize];
        }

        processPacket(in, inOff, inLen, output, 0);

        return output;
    }

    /**
     * Process a packet of data for either CCM decryption or encryption.
     *
     * @param in data for processing.
     * @param inOff offset at which data starts in the input array.
     * @param inLen length of the data in the input array.
     * @param output output array.
     * @param outOff offset into output array to start putting processed bytes.
     * @return the number of bytes added to output.
     * @throws IllegalStateException if the cipher is not appropriately set up.
     * @throws InvalidCipherTextException if the input data is truncated or the mac check fails.
     * @throws DataLengthException if output buffer too short.
     */
    public int processPacket(byte[] in, int inOff, int inLen, byte[] output, int outOff)
        throws IllegalStateException, InvalidCipherTextException, DataLengthException
    {
        // TODO: handle null keyParam (e.g. via RepeatedKeySpec)
        // Need to keep the CTR and CBC Mac parts around and reset
        if (keyParam == null)
        {
            throw new IllegalStateException("CCM cipher unitialized.");
        }

        int n = nonce.length;
        int q = 15 - n;
        if (q < 4)
        {
            int limitLen = 1 << (8 * q);
            if (inLen >= limitLen)
            {
                throw new IllegalStateException("CCM packet too large for choice of q.");
            }
        }

        byte[] iv = new byte[blockSize];
        iv[0] = (byte)((q - 1) & 0x7);
        System.arraycopy(nonce, 0, iv, 1, nonce.length);

        BlockCipher ctrCipher = new SICBlockCipher(cipher);
        ctrCipher.init(forEncryption, new ParametersWithIV(keyParam, iv));

        int outputLen;
        int inIndex = inOff;
        int outIndex = outOff;

        if (forEncryption)
        {
            outputLen = inLen + macSize;
            if (output.length < (outputLen + outOff))
            {
                throw new OutputLengthException("Output buffer too short.");
            }

            calculateMac(in, inOff, inLen, macBlock);

            byte[] encMac = new byte[blockSize];

            ctrCipher.processBlock(macBlock, 0, encMac, 0);   // S0

            while (inIndex < (inOff + inLen - blockSize))                 // S1...
            {
                ctrCipher.processBlock(in, inIndex, output, outIndex);
                outIndex += blockSize;
                inIndex += blockSize;
            }

            byte[] block = new byte[blockSize];

            System.arraycopy(in, inIndex, block, 0, inLen + inOff - inIndex);

            ctrCipher.processBlock(block, 0, block, 0);

            System.arraycopy(block, 0, output, outIndex, inLen + inOff - inIndex);

            System.arraycopy(encMac, 0, output, outOff + inLen, macSize);
        }
        else
        {
            if (inLen < macSize)
            {
                throw new InvalidCipherTextException("data too short");
            }
            outputLen = inLen - macSize;
            if (output.length < (outputLen + outOff))
            {
                throw new OutputLengthException("Output buffer too short.");
            }

            System.arraycopy(in, inOff + outputLen, macBlock, 0, macSize);

            ctrCipher.processBlock(macBlock, 0, macBlock, 0);

            for (int i = macSize; i != macBlock.length; i++)
            {
                macBlock[i] = 0;
            }

            while (inIndex < (inOff + outputLen - blockSize))
            {
                ctrCipher.processBlock(in, inIndex, output, outIndex);
                outIndex += blockSize;
                inIndex += blockSize;
            }

            byte[] block = new byte[blockSize];

            System.arraycopy(in, inIndex, block, 0, outputLen - (inIndex - inOff));

            ctrCipher.processBlock(block, 0, block, 0);

            System.arraycopy(block, 0, output, outIndex, outputLen - (inIndex - inOff));

            byte[] calculatedMacBlock = new byte[blockSize];

            calculateMac(output, outOff, outputLen, calculatedMacBlock);

            if (!Arrays.constantTimeAreEqual(macBlock, calculatedMacBlock))
            {
                throw new InvalidCipherTextException("mac check in CCM failed");
            }
        }

        return outputLen;
    }

    private int calculateMac(byte[] data, int dataOff, int dataLen, byte[] macBlock)
    {
        Mac cMac = new CBCBlockCipherMac(cipher, macSize * 8);

        cMac.init(keyParam);

        //
        // build b0
        //
        byte[] b0 = new byte[16];

        if (hasAssociatedText())
        {
            b0[0] |= 0x40;
        }

        b0[0] |= (((cMac.getMacSize() - 2) / 2) & 0x7) << 3;

        b0[0] |= ((15 - nonce.length) - 1) & 0x7;

        System.arraycopy(nonce, 0, b0, 1, nonce.length);

        int q = dataLen;
        int count = 1;
        while (q > 0)
        {
            b0[b0.length - count] = (byte)(q & 0xff);
            q >>>= 8;
            count++;
        }

        cMac.update(b0, 0, b0.length);

        //
        // process associated text
        //
        if (hasAssociatedText())
        {
            int extra;

            int textLength = getAssociatedTextLength();
            if (textLength < ((1 << 16) - (1 << 8)))
            {
                cMac.update((byte)(textLength >> 8));
                cMac.update((byte)textLength);

                extra = 2;
            }
            else // can't go any higher than 2^32
            {
                cMac.update((byte)0xff);
                cMac.update((byte)0xfe);
                cMac.update((byte)(textLength >> 24));
                cMac.update((byte)(textLength >> 16));
                cMac.update((byte)(textLength >> 8));
                cMac.update((byte)textLength);

                extra = 6;
            }

            if (initialAssociatedText != null)
            {
                cMac.update(initialAssociatedText, 0, initialAssociatedText.length);
            }
            if (associatedText.size() > 0)
            {
                cMac.update(associatedText.getBuffer(), 0, associatedText.size());
            }

            extra = (extra + textLength) % 16;
            if (extra != 0)
            {
                for (int i = extra; i != 16; i++)
                {
                    cMac.update((byte)0x00);
                }
            }
        }

        //
        // add the text
        //
        cMac.update(data, dataOff, dataLen);

        return cMac.doFinal(macBlock, 0);
    }

    private int getAssociatedTextLength()
    {
        return associatedText.size() + ((initialAssociatedText == null) ? 0 : initialAssociatedText.length);
    }

    private boolean hasAssociatedText()
    {
        return getAssociatedTextLength() > 0;
    }

    private class ExposedByteArrayOutputStream
        extends ByteArrayOutputStream
    {
        public ExposedByteArrayOutputStream()
        {
        }

        public byte[] getBuffer()
        {
            return this.buf;
        }
    }
}
