package org.bouncycastle.crypto.test;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.security.SecureRandom;

import org.bouncycastle.crypto.BlockCipher;
import org.bouncycastle.crypto.BufferedBlockCipher;
import org.bouncycastle.crypto.CipherParameters;
import org.bouncycastle.crypto.StreamCipher;
import org.bouncycastle.crypto.engines.AESEngine;
import org.bouncycastle.crypto.engines.BlowfishEngine;
import org.bouncycastle.crypto.engines.CAST5Engine;
import org.bouncycastle.crypto.engines.CAST6Engine;
import org.bouncycastle.crypto.engines.CamelliaEngine;
import org.bouncycastle.crypto.engines.ChaChaEngine;
import org.bouncycastle.crypto.engines.DESEngine;
import org.bouncycastle.crypto.engines.DESedeEngine;
import org.bouncycastle.crypto.engines.Grain128Engine;
import org.bouncycastle.crypto.engines.Grainv1Engine;
import org.bouncycastle.crypto.engines.HC128Engine;
import org.bouncycastle.crypto.engines.HC256Engine;
import org.bouncycastle.crypto.engines.NoekeonEngine;
import org.bouncycastle.crypto.engines.RC2Engine;
import org.bouncycastle.crypto.engines.RC4Engine;
import org.bouncycastle.crypto.engines.RC6Engine;
import org.bouncycastle.crypto.engines.SEEDEngine;
import org.bouncycastle.crypto.engines.Salsa20Engine;
import org.bouncycastle.crypto.engines.SerpentEngine;
import org.bouncycastle.crypto.engines.TEAEngine;
import org.bouncycastle.crypto.engines.ThreefishEngine;
import org.bouncycastle.crypto.engines.TwofishEngine;
import org.bouncycastle.crypto.engines.XSalsa20Engine;
import org.bouncycastle.crypto.engines.XTEAEngine;
import org.bouncycastle.crypto.io.CipherInputStream;
import org.bouncycastle.crypto.io.CipherOutputStream;
import org.bouncycastle.crypto.io.InvalidCipherTextIOException;
import org.bouncycastle.crypto.modes.AEADBlockCipher;
import org.bouncycastle.crypto.modes.CBCBlockCipher;
import org.bouncycastle.crypto.modes.CCMBlockCipher;
import org.bouncycastle.crypto.modes.CFBBlockCipher;
import org.bouncycastle.crypto.modes.CTSBlockCipher;
import org.bouncycastle.crypto.modes.EAXBlockCipher;
import org.bouncycastle.crypto.modes.GCMBlockCipher;
import org.bouncycastle.crypto.modes.NISTCTSBlockCipher;
import org.bouncycastle.crypto.modes.OCBBlockCipher;
import org.bouncycastle.crypto.modes.OFBBlockCipher;
import org.bouncycastle.crypto.modes.SICBlockCipher;
import org.bouncycastle.crypto.paddings.PKCS7Padding;
import org.bouncycastle.crypto.paddings.PaddedBufferedBlockCipher;
import org.bouncycastle.crypto.params.KeyParameter;
import org.bouncycastle.crypto.params.ParametersWithIV;
import org.bouncycastle.util.Arrays;
import org.bouncycastle.util.test.SimpleTest;

public class CipherStreamTest
    extends SimpleTest
{
    private int streamSize;

    public String getName()
    {
        return "CipherStreamTest";
    }

    private void testMode(Object cipher, CipherParameters params)
        throws Exception
    {
        testWriteRead(cipher, params, false);
        testWriteRead(cipher, params, true);
        testReadWrite(cipher, params, false);
        testReadWrite(cipher, params, true);

        if (!(cipher instanceof CTSBlockCipher || cipher instanceof NISTCTSBlockCipher))
        {
            testWriteReadEmpty(cipher, params, false);
            testWriteReadEmpty(cipher, params, true);
        }

        if (cipher instanceof AEADBlockCipher)
        {
            testTamperedRead((AEADBlockCipher)cipher, params);
            testTruncatedRead((AEADBlockCipher)cipher, params);
            testTamperedWrite((AEADBlockCipher)cipher, params);
        }
    }

    private OutputStream createCipherOutputStream(OutputStream output, Object cipher)
    {
        if (cipher instanceof BufferedBlockCipher)
        {
            return new CipherOutputStream(output, (BufferedBlockCipher)cipher);
        }
        else if (cipher instanceof AEADBlockCipher)
        {
            return new CipherOutputStream(output, (AEADBlockCipher)cipher);
        }
        else
        {
            return new CipherOutputStream(output, (StreamCipher)cipher);
        }
    }

    private InputStream createCipherInputStream(byte[] data, Object cipher)
    {
        ByteArrayInputStream input = new ByteArrayInputStream(data);
        if (cipher instanceof BufferedBlockCipher)
        {
            return new CipherInputStream(input, (BufferedBlockCipher)cipher);
        }
        else if (cipher instanceof AEADBlockCipher)
        {
            return new CipherInputStream(input, (AEADBlockCipher)cipher);
        }
        else
        {
            return new CipherInputStream(input, (StreamCipher)cipher);
        }
    }

    /**
     * Test tampering of ciphertext followed by read from decrypting CipherInputStream
     */
    private void testTamperedRead(AEADBlockCipher cipher, CipherParameters params)
        throws Exception
    {
        cipher.init(true, params);

        byte[] ciphertext = new byte[cipher.getOutputSize(streamSize)];
        cipher.doFinal(ciphertext, cipher.processBytes(new byte[streamSize], 0, streamSize, ciphertext, 0));

        // Tamper
        ciphertext[0] += 1;

        cipher.init(false, params);
        InputStream input = createCipherInputStream(ciphertext, cipher);
        try
        {
            while (input.read() >= 0)
            {
            }
            fail("Expected invalid ciphertext after tamper and read : " + cipher.getAlgorithmName());
        }
        catch (InvalidCipherTextIOException e)
        {
            // Expected
        }
        try
        {
            input.close();
        }
        catch (Exception e)
        {
            fail("Unexpected exception after tamper and read : " + cipher.getAlgorithmName());
        }
    }

    /**
     * Test truncation of ciphertext to make tag calculation impossible, followed by read from
     * decrypting CipherInputStream
     */
    private void testTruncatedRead(AEADBlockCipher cipher, CipherParameters params)
        throws Exception
    {
        cipher.init(true, params);

        byte[] ciphertext = new byte[cipher.getOutputSize(streamSize)];
        cipher.doFinal(ciphertext, cipher.processBytes(new byte[streamSize], 0, streamSize, ciphertext, 0));

        // Truncate to just smaller than complete tag
        byte[] truncated = new byte[ciphertext.length - streamSize - 1];
        System.arraycopy(ciphertext, 0, truncated, 0, truncated.length);

        cipher.init(false, params);
        InputStream input = createCipherInputStream(truncated, cipher);
        while (true)
        {
            int read = 0;
            try
            {
                read = input.read();
            }
            catch (InvalidCipherTextIOException e)
            {
                // Expected
                break;
            }
            catch (Exception e)
            {
                fail("Unexpected exception  on truncated read : " + cipher.getAlgorithmName());
                break;
            }
            if (read < 0)
            {
                fail("Expected invalid ciphertext after truncate and read : " + cipher.getAlgorithmName());
                break;
            }
        }
        try
        {
            input.close();
        }
        catch (Exception e)
        {
            fail("Unexpected exception after truncate and read : " + cipher.getAlgorithmName());
        }
    }

    /**
     * Test tampering of ciphertext followed by write to decrypting CipherOutputStream
     */
    private void testTamperedWrite(AEADBlockCipher cipher, CipherParameters params)
        throws Exception
    {
        cipher.init(true, params);

        byte[] ciphertext = new byte[cipher.getOutputSize(streamSize)];
        cipher.doFinal(ciphertext, cipher.processBytes(new byte[streamSize], 0, streamSize, ciphertext, 0));

        // Tamper
        ciphertext[0] += 1;

        cipher.init(false, params);
        ByteArrayOutputStream plaintext = new ByteArrayOutputStream();
        OutputStream output = createCipherOutputStream(plaintext, cipher);

        for (int i = 0; i < ciphertext.length; i++)
        {
            output.write(ciphertext[i]);
        }
        try
        {
            output.close();
            fail("Expected invalid ciphertext after tamper and write : " + cipher.getAlgorithmName());
        }
        catch (InvalidCipherTextIOException e)
        {
            // Expected
        }
    }

    /**
     * Test CipherOutputStream in ENCRYPT_MODE, CipherInputStream in DECRYPT_MODE
     */
    private void testWriteRead(Object cipher, CipherParameters params, boolean blocks)
        throws Exception
    {
        byte[] data = new byte[streamSize];
        for (int i = 0; i < data.length; i++)
        {
            data[i] = (byte)(i % 255);
        }

        testWriteRead(cipher, params, blocks, data);
    }

    /**
     * Test CipherOutputStream in ENCRYPT_MODE, CipherInputStream in DECRYPT_MODE
     */
    private void testWriteReadEmpty(Object cipher, CipherParameters params, boolean blocks)
        throws Exception
    {
        byte[] data = new byte[0];

        testWriteRead(cipher, params, blocks, data);
    }

    private void testWriteRead(Object cipher, CipherParameters params, boolean blocks, byte[] data)
    {
        ByteArrayOutputStream bOut = new ByteArrayOutputStream();

        try
        {
            init(cipher, true, params);

            OutputStream cOut = createCipherOutputStream(bOut, cipher);
            if (blocks)
            {
                int chunkSize = Math.max(1, data.length / 8);
                for (int i = 0; i < data.length; i += chunkSize)
                {
                    cOut.write(data, i, Math.min(chunkSize, data.length - i));
                }
            }
            else
            {
                for (int i = 0; i < data.length; i++)
                {
                    cOut.write(data[i]);
                }
            }
            cOut.close();

            byte[] cipherText = bOut.toByteArray();
            bOut.reset();
            init(cipher, false, params);
            InputStream cIn = createCipherInputStream(cipherText, cipher);

            if (blocks)
            {
                byte[] block = new byte[getBlockSize(cipher) + 1];
                int c;
                while ((c = cIn.read(block)) >= 0)
                {
                    bOut.write(block, 0, c);
                }
            }
            else
            {
                int c;
                while ((c = cIn.read()) >= 0)
                {
                    bOut.write(c);
                }

            }
            cIn.close();

        }
        catch (Exception e)
        {
            fail("Unexpected exception " + getName(cipher), e);
        }

        byte[] decrypted = bOut.toByteArray();
        if (!Arrays.areEqual(data, decrypted))
        {
            fail("Failed - decrypted data doesn't match: " + getName(cipher));
        }
    }

    private String getName(Object cipher)
    {
        if (cipher instanceof BufferedBlockCipher)
        {
            return ((BufferedBlockCipher)cipher).getUnderlyingCipher().getAlgorithmName();
        }
        else if (cipher instanceof AEADBlockCipher)
        {
            return ((AEADBlockCipher)cipher).getUnderlyingCipher().getAlgorithmName();
        }
        else if (cipher instanceof StreamCipher)
        {
            return ((StreamCipher)cipher).getAlgorithmName();
        }
        return null;
    }

    private int getBlockSize(Object cipher)
    {
        if (cipher instanceof BlockCipher)
        {
            return ((BlockCipher)cipher).getBlockSize();
        }
        else if (cipher instanceof BufferedBlockCipher)
        {
            return ((BufferedBlockCipher)cipher).getBlockSize();
        }
        else if (cipher instanceof AEADBlockCipher)
        {
            return ((AEADBlockCipher)cipher).getUnderlyingCipher().getBlockSize();
        }
        else if (cipher instanceof StreamCipher)
        {
            return 1;
        }
        return 0;
    }

    private void init(Object cipher, boolean forEncrypt, CipherParameters params)
    {
        if (cipher instanceof BufferedBlockCipher)
        {
            ((BufferedBlockCipher)cipher).init(forEncrypt, params);
        }
        else if (cipher instanceof AEADBlockCipher)
        {
            ((AEADBlockCipher)cipher).init(forEncrypt, params);
        }
        else if (cipher instanceof StreamCipher)
        {
            ((StreamCipher)cipher).init(forEncrypt, params);
        }
    }

    protected void fail(String message, boolean authenticated, boolean bc)
    {
        if (bc || !authenticated)
        {
            super.fail(message);
        }
        else
        {
            // javax.crypto.CipherInputStream/CipherOutputStream
            // are broken wrt handling AEAD failures
            System.err.println("Broken JCE Streams: " + message);
        }
    }

    /**
     * Test CipherInputStream in ENCRYPT_MODE, CipherOutputStream in DECRYPT_MODE
     */
    private void testReadWrite(Object cipher, CipherParameters params, boolean blocks)
        throws Exception
    {
        String lCode = "ABCDEFGHIJKLMNOPQRSTU";

        ByteArrayOutputStream bOut = new ByteArrayOutputStream();

        try
        {
            init(cipher, true, params);

            InputStream cIn = createCipherInputStream(lCode.getBytes(), cipher);
            ByteArrayOutputStream ct = new ByteArrayOutputStream();

            if (blocks)
            {
                byte[] block = new byte[getBlockSize(cipher) + 1];
                int c;
                while ((c = cIn.read(block)) >= 0)
                {
                    ct.write(block, 0, c);
                }
            }
            else
            {
                int c;
                while ((c = cIn.read()) >= 0)
                {
                    ct.write(c);
                }
            }
            cIn.close();

            init(cipher, false, params);
            ByteArrayInputStream dataIn = new ByteArrayInputStream(ct.toByteArray());
            OutputStream cOut = createCipherOutputStream(bOut, cipher);

            if (blocks)
            {
                byte[] block = new byte[getBlockSize(cipher) + 1];
                int c;
                while ((c = dataIn.read(block)) >= 0)
                {
                    cOut.write(block, 0, c);
                }
            }
            else
            {
                int c;
                while ((c = dataIn.read()) >= 0)
                {
                    cOut.write(c);
                }
            }
            cOut.flush();
            cOut.close();

        }
        catch (Exception e)
        {
            fail("Unexpected exception " + getName(cipher), e);
        }

        String res = new String(bOut.toByteArray());
        if (!res.equals(lCode))
        {
            fail("Failed read/write - decrypted data doesn't match: " + getName(cipher), lCode, res);
        }
    }

    public void performTest()
        throws Exception
    {
        int[] testSizes = new int[]{0, 1, 7, 8, 9, 15, 16, 17, 1023, 1024, 1025, 2047, 2048, 2049, 4095, 4096, 4097};
        for (int i = 0; i < testSizes.length; i++)
        {
            this.streamSize = testSizes[i];
            performTests();
        }
    }

    private void performTests()
        throws Exception
    {
        testModes(new BlowfishEngine(), new BlowfishEngine(), 16);
        testModes(new DESEngine(), new DESEngine(), 8);
        testModes(new DESedeEngine(), new DESedeEngine(), 24);
        testModes(new TEAEngine(), new TEAEngine(), 16);
        testModes(new CAST5Engine(), new CAST5Engine(), 16);
        testModes(new RC2Engine(), new RC2Engine(), 16);
        testModes(new XTEAEngine(), new XTEAEngine(), 16);

        testModes(new AESEngine(), new AESEngine(), 16);
        testModes(new NoekeonEngine(), new NoekeonEngine(), 16);
        testModes(new TwofishEngine(), new TwofishEngine(), 16);
        testModes(new CAST6Engine(), new CAST6Engine(), 16);
        testModes(new SEEDEngine(), new SEEDEngine(), 16);
        testModes(new SerpentEngine(), new SerpentEngine(), 16);
        testModes(new RC6Engine(), new RC6Engine(), 16);
        testModes(new CamelliaEngine(), new CamelliaEngine(), 16);
        testModes(new ThreefishEngine(ThreefishEngine.BLOCKSIZE_512),
            new ThreefishEngine(ThreefishEngine.BLOCKSIZE_512), 64);

        testMode(new RC4Engine(), new KeyParameter(new byte[16]));
        testMode(new Salsa20Engine(), new ParametersWithIV(new KeyParameter(new byte[16]), new byte[8]));
        testMode(new XSalsa20Engine(), new ParametersWithIV(new KeyParameter(new byte[32]), new byte[24]));
        testMode(new ChaChaEngine(), new ParametersWithIV(new KeyParameter(new byte[16]), new byte[8]));
        testMode(new Grainv1Engine(), new ParametersWithIV(new KeyParameter(new byte[16]), new byte[8]));
        testMode(new Grain128Engine(), new ParametersWithIV(new KeyParameter(new byte[16]), new byte[12]));
        testMode(new HC128Engine(), new KeyParameter(new byte[16]));
        testMode(new HC256Engine(), new ParametersWithIV(new KeyParameter(new byte[16]), new byte[16]));

        testSkipping(new Salsa20Engine(), new ParametersWithIV(new KeyParameter(new byte[16]), new byte[8]));
        testSkipping(new SICBlockCipher(new AESEngine()), new ParametersWithIV(new KeyParameter(new byte[16]), new byte[16]));
    }

    private void testModes(BlockCipher cipher1, BlockCipher cipher2, int keySize)
        throws Exception
    {
        final KeyParameter key = new KeyParameter(new byte[keySize]);
        final int blockSize = getBlockSize(cipher1);
        final CipherParameters withIv = new ParametersWithIV(key, new byte[blockSize]);

        if (blockSize > 1)
        {
            testMode(new PaddedBufferedBlockCipher(cipher1, new PKCS7Padding()), key);

            testMode(new PaddedBufferedBlockCipher(new CBCBlockCipher(cipher1), new PKCS7Padding()), withIv);

            testMode(new BufferedBlockCipher(new OFBBlockCipher(cipher1, blockSize)), withIv);
            testMode(new BufferedBlockCipher(new CFBBlockCipher(cipher1, blockSize)), withIv);
            testMode(new BufferedBlockCipher(new SICBlockCipher(cipher1)), withIv);
        }
        // CTS requires at least one block
        if (blockSize <= 16 && streamSize >= blockSize)
        {
            testMode(new CTSBlockCipher(cipher1), key);
        }
        if (blockSize <= 16 && streamSize >= blockSize)
        {
            testMode(new NISTCTSBlockCipher(NISTCTSBlockCipher.CS1, cipher1), key);
            testMode(new NISTCTSBlockCipher(NISTCTSBlockCipher.CS2, cipher1), key);
            testMode(new NISTCTSBlockCipher(NISTCTSBlockCipher.CS3, cipher1), key);
        }
        if (blockSize == 8 || blockSize == 16)
        {
            testMode(new EAXBlockCipher(cipher1), withIv);
        }
        if (blockSize == 16)
        {
            testMode(new CCMBlockCipher(cipher1), new ParametersWithIV(key, new byte[7]));
            testMode(new GCMBlockCipher(cipher1), withIv);
            testMode(new OCBBlockCipher(cipher1, cipher2), new ParametersWithIV(key, new byte[15]));
        }
    }

    private void testSkipping(StreamCipher cipher, CipherParameters params)
        throws Exception
    {
        ByteArrayOutputStream bOut = new ByteArrayOutputStream();

        init(cipher, true, params);

        OutputStream cOut = createCipherOutputStream(bOut, cipher);
        byte[] data = new byte[5000];

        new SecureRandom().nextBytes(data);

        cOut.write(data);

        cOut.close();

        init(cipher, false, params);

        InputStream cIn = createCipherInputStream(bOut.toByteArray(), cipher);

        long skip = cIn.skip(50);
        if (skip != 50)
        {
            fail("wrong number of bytes skipped: " + skip);
        }

        byte[] block = new byte[50];

        cIn.read(block);

        if (!areEqual(data, 50, block, 0))
        {
            fail("initial skip mismatch");
        }

        skip = cIn.skip(3000);
        if (skip != 3000)
        {
            fail("wrong number of bytes skipped: " + skip);
        }

        cIn.read(block);

        if (!areEqual(data, 3100, block, 0))
        {
            fail("second skip mismatch");
        }

        cipher.reset();

        cIn = createCipherInputStream(bOut.toByteArray(), cipher);
        if (!cIn.markSupported())
        {
            fail("marking not supported");
        }

        cIn.mark(100);

        cIn.read(block);

        if (!areEqual(data, 0, block, 0))
        {
            fail("initial mark read failed");
        }

        cIn.reset();

        cIn.read(block);

        if (!areEqual(data, 0, block, 0))
        {
            fail(cipher.getAlgorithmName() + " initial reset read failed");
        }

        cIn.reset();

        cIn.read(block);

        cIn.mark(100);

        cIn.read(block);

        if (!areEqual(data, 50, block, 0))
        {
            fail("second mark read failed");
        }

        cIn.reset();

        cIn.read(block);

        if (!areEqual(data, 50, block, 0))
        {
            fail(cipher.getAlgorithmName() + " second reset read failed");
        }

        cIn.mark(3000);

        skip = cIn.skip(2050);
        if (skip != 2050)
        {
            fail("wrong number of bytes skipped: " + skip);
        }

        cIn.reset();

        cIn.read(block);

        if (!areEqual(data, 100, block, 0))
        {
            fail(cipher.getAlgorithmName() + " third reset read failed");
        }

        cIn.read(new byte[2150]);

        cIn.reset();

        cIn.read(block);

        if (!areEqual(data, 100, block, 0))
        {
            fail(cipher.getAlgorithmName() + " fourth reset read failed");
        }

        cIn.close();
    }

    private boolean areEqual(byte[] a, int aOff, byte[] b, int bOff)
    {
        for (int i = bOff; i != b.length; i++)
        {
            if (a[aOff + i - bOff] != b[i])
            {
                return false;
            }
        }

        return true;
    }

    public static void main(String[] args)
    {
        runTest(new CipherStreamTest());
    }

}
