package org.bouncycastle.jce.provider.test;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.security.Key;
import java.security.Security;

import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.spec.IvParameterSpec;

import org.bouncycastle.crypto.io.InvalidCipherTextIOException;
import org.bouncycastle.jcajce.io.CipherInputStream;
import org.bouncycastle.jcajce.io.CipherOutputStream;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.util.Arrays;
import org.bouncycastle.util.test.SimpleTest;

public class CipherStreamTest2
    extends SimpleTest
{
    private int streamSize;

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

    private void testModes(String algo, String[] transforms, boolean authenticated)
        throws Exception
    {
        Key key = generateKey(algo);
        for (int i = 0; i != transforms.length; i++)
        {
            String transform = transforms[i];
            String cipherName = algo + transform;

            boolean cts = transform.indexOf("CTS") > -1;
            if (cts && streamSize < Cipher.getInstance(cipherName, "BC").getBlockSize())
            {
                continue;
            }
            testWriteRead(cipherName, key, authenticated, true, false);
            testWriteRead(cipherName, key, authenticated, true, true);
            testWriteRead(cipherName, key, authenticated, false, false);
            testWriteRead(cipherName, key, authenticated, false, true);
            testReadWrite(cipherName, key, authenticated, true, false);
            testReadWrite(cipherName, key, authenticated, true, true);
            testReadWrite(cipherName, key, authenticated, false, false);
            testReadWrite(cipherName, key, authenticated, false, true);

            if (!cts)
            {
                testWriteReadEmpty(cipherName, key, authenticated, true, false);
                testWriteReadEmpty(cipherName, key, authenticated, true, true);
                testWriteReadEmpty(cipherName, key, authenticated, false, false);
                testWriteReadEmpty(cipherName, key, authenticated, false, true);
            }

            if (authenticated)
            {
                testTamperedRead(cipherName, key, true, true);
                testTamperedRead(cipherName, key, true, false);
                testTruncatedRead(cipherName, key, true, true);
                testTruncatedRead(cipherName, key, true, false);
                testTamperedWrite(cipherName, key, true, true);
                testTamperedWrite(cipherName, key, true, false);
            }
        }
    }

    private InputStream createInputStream(byte[] data, Cipher cipher, boolean useBc)
    {
        ByteArrayInputStream bytes = new ByteArrayInputStream(data);
        // cast required for earlier JDK
        return useBc ? (InputStream)new CipherInputStream(bytes, cipher) : (InputStream)new javax.crypto.CipherInputStream(bytes, cipher);
    }

    private OutputStream createOutputStream(ByteArrayOutputStream bytes, Cipher cipher, boolean useBc)
    {
        // cast required for earlier JDK
        return useBc ? (OutputStream)new CipherOutputStream(bytes, cipher) : (OutputStream)new javax.crypto.CipherOutputStream(bytes, cipher);
    }

    /**
     * Test tampering of ciphertext followed by read from decrypting CipherInputStream
     */
    private void testTamperedRead(String name, Key key, boolean authenticated, boolean useBc)
        throws Exception
    {
        Cipher encrypt = Cipher.getInstance(name, "BC");
        Cipher decrypt = Cipher.getInstance(name, "BC");
        encrypt.init(Cipher.ENCRYPT_MODE, key);
        if (encrypt.getIV() != null)
        {
            decrypt.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(encrypt.getIV()));
        }
        else
        {
            decrypt.init(Cipher.DECRYPT_MODE, key);
        }

        byte[] ciphertext = encrypt.doFinal(new byte[streamSize]);

        // Tamper
        ciphertext[0] += 1;

        InputStream input = createInputStream(ciphertext, decrypt, useBc);
        try
        {
            while (input.read() >= 0)
            {
            }
            fail("Expected invalid ciphertext after tamper and read : " + name, authenticated, useBc);
        }
        catch (InvalidCipherTextIOException e)
        {
            // Expected
        }
        try
        {
            input.close();
        }
        catch (Exception e)
        {
            fail("Unexpected exception : " + name, e, authenticated, useBc);
        }
    }

    /**
     * Test truncation of ciphertext to make tag calculation impossible, followed by read from
     * decrypting CipherInputStream
     */
    private void testTruncatedRead(String name, Key key, boolean authenticated, boolean useBc)
        throws Exception
    {
        Cipher encrypt = Cipher.getInstance(name, "BC");
        Cipher decrypt = Cipher.getInstance(name, "BC");
        encrypt.init(Cipher.ENCRYPT_MODE, key);
        if (encrypt.getIV() != null)
        {
            decrypt.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(encrypt.getIV()));
        }
        else
        {
            decrypt.init(Cipher.DECRYPT_MODE, key);
        }

        byte[] ciphertext = encrypt.doFinal(new byte[streamSize]);

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

        // Tamper
        ciphertext[0] += 1;

        InputStream input = createInputStream(truncated, decrypt, useBc);
        while (true)
        {
            int read = 0;
            try
            {
                read = input.read();
            }
            catch (InvalidCipherTextIOException e)
            {
                // Expected
                break;
            }
            catch (Exception e)
            {
                fail("Unexpected exception : " + name, e, authenticated, useBc);
                break;
            }
            if (read < 0)
            {
                fail("Expected invalid ciphertext after truncate and read : " + name, authenticated, useBc);
                break;
            }
        }
        try
        {
            input.close();
        }
        catch (Exception e)
        {
            fail("Unexpected exception : " + name, e, authenticated, useBc);
        }
    }

    /**
     * Test tampering of ciphertext followed by write to decrypting CipherOutputStream
     */
    private void testTamperedWrite(String name, Key key, boolean authenticated, boolean useBc)
        throws Exception
    {
        Cipher encrypt = Cipher.getInstance(name, "BC");
        Cipher decrypt = Cipher.getInstance(name, "BC");
        encrypt.init(Cipher.ENCRYPT_MODE, key);
        if (encrypt.getIV() != null)
        {
            decrypt.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(encrypt.getIV()));
        }
        else
        {
            decrypt.init(Cipher.DECRYPT_MODE, key);
        }

        byte[] ciphertext = encrypt.doFinal(new byte[streamSize]);

        // Tamper
        ciphertext[0] += 1;

        ByteArrayOutputStream plaintext = new ByteArrayOutputStream();
        OutputStream output = createOutputStream(plaintext, decrypt, useBc);

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

    /**
     * Test CipherOutputStream in ENCRYPT_MODE, CipherInputStream in DECRYPT_MODE
     */
    private void testWriteRead(String name, Key key, boolean authenticated, boolean useBc, boolean blocks)
        throws Exception
    {
        byte[] data = new byte[streamSize];
        for (int i = 0; i < data.length; i++)
        {
            data[i] = (byte)(i % 255);
        }

        testWriteRead(name, key, authenticated, useBc, blocks, data);
    }

    /**
     * Test CipherOutputStream in ENCRYPT_MODE, CipherInputStream in DECRYPT_MODE
     */
    private void testWriteReadEmpty(String name, Key key, boolean authenticated, boolean useBc, boolean blocks)
        throws Exception
    {
        byte[] data = new byte[0];

        testWriteRead(name, key, authenticated, useBc, blocks, data);
    }

    private void testWriteRead(String name, Key key, boolean authenticated, boolean useBc, boolean blocks, byte[] data)
    {
        ByteArrayOutputStream bOut = new ByteArrayOutputStream();

        try
        {
            Cipher encrypt = Cipher.getInstance(name, "BC");
            Cipher decrypt = Cipher.getInstance(name, "BC");
            encrypt.init(Cipher.ENCRYPT_MODE, key);
            if (encrypt.getIV() != null)
            {
                decrypt.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(encrypt.getIV()));
            }
            else
            {
                decrypt.init(Cipher.DECRYPT_MODE, key);
            }

            OutputStream cOut = createOutputStream(bOut, encrypt, useBc);
            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();
            InputStream cIn = createInputStream(cipherText, decrypt, useBc);

            if (blocks)
            {
                byte[] block = new byte[encrypt.getBlockSize() + 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 " + name, e, authenticated, useBc);
        }

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

    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);
        }
    }

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

    /**
     * Test CipherInputStream in ENCRYPT_MODE, CipherOutputStream in DECRYPT_MODE
     */
    private void testReadWrite(String name, Key key, boolean authenticated, boolean useBc, boolean blocks)
        throws Exception
    {
        String lCode = "ABCDEFGHIJKLMNOPQRSTU";

        ByteArrayOutputStream bOut = new ByteArrayOutputStream();

        try
        {
            Cipher in = Cipher.getInstance(name, "BC");
            Cipher out = Cipher.getInstance(name, "BC");
            in.init(Cipher.ENCRYPT_MODE, key);
            if (in.getIV() != null)
            {
                out.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(in.getIV()));
            }
            else
            {
                out.init(Cipher.DECRYPT_MODE, key);
            }

            InputStream cIn = createInputStream(lCode.getBytes(), in, useBc);
            OutputStream cOut = createOutputStream(bOut, out, useBc);

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

            cIn.close();

            cOut.flush();
            cOut.close();

        }
        catch (Exception e)
        {
            fail("Unexpected exception " + name, e, authenticated, useBc);
        }

        String res = new String(bOut.toByteArray());
        if (!res.equals(lCode))
        {
            fail("Failed - decrypted data doesn't match: " + name, authenticated, useBc);
        }
    }

    private static Key generateKey(String name)
        throws Exception
    {
        KeyGenerator kGen;

        if (name.indexOf('/') < 0)
        {
            kGen = KeyGenerator.getInstance(name, "BC");
        }
        else
        {
            kGen = KeyGenerator.getInstance(name.substring(0, name.indexOf('/')), "BC");
        }
        return kGen.generateKey();
    }

    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
    {
        final String[] blockCiphers64 = new String[]{"BLOWFISH", "DES", "DESEDE", "TEA", "CAST5", "RC2", "XTEA"};

        for (int i = 0; i != blockCiphers64.length; i++)
        {
            testModes(blockCiphers64[i], new String[]{
                "/ECB/PKCS5Padding",
                "/CBC/PKCS5Padding",
                "/OFB/NoPadding",
                "/CFB/NoPadding",
                "/CTS/NoPadding",}, false);
            testModes(blockCiphers64[i], new String[]{"/EAX/NoPadding"}, true);
        }

        final String[] blockCiphers128 = new String[]{
            "AES",
            "NOEKEON",
            "Twofish",
            "CAST6",
            "SEED",
            "Serpent",
            "RC6",
            "CAMELLIA"};

        for (int i = 0; i != blockCiphers128.length; i++)
        {
            testModes(blockCiphers128[i], new String[]{
                "/ECB/PKCS5Padding",
                "/CBC/PKCS5Padding",
                "/OFB/NoPadding",
                "/CFB/NoPadding",
                "/CTS/NoPadding",
                "/CTR/NoPadding",
                "/SIC/NoPadding"}, false);
            testModes(blockCiphers128[i], new String[]{"/CCM/NoPadding", "/EAX/NoPadding", "/GCM/NoPadding", "/OCB/NoPadding"}, true);
        }

        final String[] streamCiphers = new String[]{
            "ARC4",
            "SALSA20",
            "XSalsa20",
            "ChaCha",
            "Grainv1",
            "Grain128",
            "HC128",
            "HC256"};

        for (int i = 0; i != streamCiphers.length; i++)
        {
            testModes(streamCiphers[i], new String[]{""}, false);
        }
    }

    public static void main(String[] args)
    {
        Security.addProvider(new BouncyCastleProvider());
        runTest(new CipherStreamTest2());
    }

}
