/*
 * 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.ByteArrayInputStream;
import java.security.Key;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Collections;
import java.util.Map;

import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;

import org.apache.camel.CamelContext;
import org.apache.camel.CamelExecutionException;
import org.apache.camel.Exchange;
import org.apache.camel.Processor;
import org.apache.camel.builder.RouteBuilder;
import org.apache.camel.component.mock.MockEndpoint;
import org.apache.camel.test.junit6.CamelTestSupport;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

public class CryptoDataFormatTest extends CamelTestSupport {

    @Test
    void testBasicSymmetric() throws Exception {
        doRoundTripEncryptionTests("direct:basic-encryption");
    }

    @Test
    void testSymmetricWithInitVector() throws Exception {
        doRoundTripEncryptionTests("direct:init-vector");
    }

    @Test
    void testSymmetricWithInlineInitVector() throws Exception {
        doRoundTripEncryptionTests("direct:inline");
    }

    @Test
    void testSymmetricWithHMAC() throws Exception {
        doRoundTripEncryptionTests("direct:hmac");
    }

    @Test
    void testSymmetricWithMD5HMAC() throws Exception {
        doRoundTripEncryptionTests("direct:hmac-algorithm");
    }

    @Test
    void testSymmetricWithSHA256HMAC() throws Exception {
        doRoundTripEncryptionTests("direct:hmac-sha-256-algorithm");
    }

    @Test
    void testKeySuppliedAsHeader() throws Exception {
        KeyGenerator generator = KeyGenerator.getInstance("DES");
        Key key = generator.generateKey();

        Exchange unecrypted = getMandatoryEndpoint("direct:key-in-header-encrypt").createExchange();
        unecrypted.getIn().setBody("Hi Alice, Be careful Eve is listening, signed Bob");
        unecrypted.getIn().setHeader(CryptoDataFormat.KEY, key);
        unecrypted = template.send("direct:key-in-header-encrypt", unecrypted);
        validateHeaderIsCleared(unecrypted);

        MockEndpoint mock = setupExpectations(context, 1, "mock:unencrypted");
        Exchange encrypted = getMandatoryEndpoint("direct:key-in-header-decrypt").createExchange();
        encrypted.getIn().copyFrom(unecrypted.getIn());
        encrypted.getIn().setHeader(CryptoDataFormat.KEY, key);
        template.send("direct:key-in-header-decrypt", encrypted);

        MockEndpoint.assertIsSatisfied(context);

        Exchange received = mock.getReceivedExchanges().get(0);
        validateHeaderIsCleared(received);
    }

    @Test
    void test3DESECBSymmetric() throws Exception {
        doRoundTripEncryptionTests("direct:3des-ecb-encryption");
    }

    @Test
    void test3DESCBCSymmetric() throws Exception {
        doRoundTripEncryptionTests("direct:3des-cbc-encryption");
    }

    @Test
    void testAES128ECBSymmetric() throws Exception {
        if (checkUnrestrictedPoliciesInstalled()) {
            doRoundTripEncryptionTests("direct:aes-128-ecb-encryption");
        }
    }

    @Test
    void testAES128GCMSymmetric() throws Exception {
        if (checkUnrestrictedPoliciesInstalled()) {
            doRoundTripEncryptionTests("direct:aes-gcm-encryption");
        }
    }

    @Test
    void testInlineAES128GCMSymmetric() throws Exception {
        if (checkUnrestrictedPoliciesInstalled()) {
            doRoundTripEncryptionTests("direct:inline-aes-gcm-encryption");
        }
    }

    @Test
    void testNoAlgorithm() throws Exception {
        CamelExecutionException ex = assertThrows(CamelExecutionException.class,
                () -> doRoundTripEncryptionTests("direct:no-algorithm"),
                "Failure expected on no algorithm specified");
        assertTrue(ex.getCause() instanceof NoSuchAlgorithmException);
    }

    private void validateHeaderIsCleared(Exchange ex) {
        Object header = ex.getIn().getHeader(CryptoDataFormat.KEY);
        assertTrue(!ex.getIn().getHeaders().containsKey(CryptoDataFormat.KEY) || "".equals(header) || header == null);
    }

    private void doRoundTripEncryptionTests(String endpointUri) throws Exception {
        doRoundTripEncryptionTests(endpointUri, Collections.<String, Object> emptyMap());
    }

    private void doRoundTripEncryptionTests(String endpoint, Map<String, Object> headers) throws Exception {
        MockEndpoint encrypted = setupExpectations(context, 3, "mock:encrypted");
        MockEndpoint unencrypted = setupExpectations(context, 3, "mock:unencrypted");

        String payload = "Hi Alice, Be careful Eve is listening, signed Bob";
        template.sendBodyAndHeaders(endpoint, payload, headers);
        template.sendBodyAndHeaders(endpoint, payload.getBytes(), headers);
        template.sendBodyAndHeaders(endpoint, new ByteArrayInputStream(payload.getBytes()), headers);

        assertMocksSatisfied(encrypted, unencrypted, payload);
    }

    private void assertMocksSatisfied(MockEndpoint encrypted, MockEndpoint unencrypted, String payload) throws Exception {
        awaitAndAssert(unencrypted);
        awaitAndAssert(encrypted);
        for (Exchange e : unencrypted.getReceivedExchanges()) {
            assertEquals(payload, e.getIn().getMandatoryBody(String.class));
        }
        for (Exchange e : encrypted.getReceivedExchanges()) {
            byte[] ciphertext = e.getIn().getMandatoryBody(byte[].class);
            assertNotSame(payload, new String(ciphertext));
        }
    }

    @Override
    protected RouteBuilder[] createRouteBuilders() {
        return new RouteBuilder[] { new RouteBuilder() {
            public void configure() throws Exception {
                // START SNIPPET: basic
                KeyGenerator generator = KeyGenerator.getInstance("DES");

                CryptoDataFormat cryptoFormat = new CryptoDataFormat("DES", generator.generateKey());

                from("direct:basic-encryption")
                        .marshal(cryptoFormat)
                        .to("mock:encrypted")
                        .unmarshal(cryptoFormat)
                        .to("mock:unencrypted");
                // END SNIPPET: basic
            }
        }, new RouteBuilder() {
            public void configure() throws Exception {
                // START SNIPPET: init-vector
                KeyGenerator generator = KeyGenerator.getInstance("DES");
                byte[] initializationVector = new byte[] { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07 };

                CryptoDataFormat cryptoFormat = new CryptoDataFormat("DES/CBC/PKCS5Padding", generator.generateKey());
                cryptoFormat.setInitVector(initializationVector);

                from("direct:init-vector")
                        .marshal(cryptoFormat)
                        .to("mock:encrypted")
                        .unmarshal(cryptoFormat)
                        .to("mock:unencrypted");
                // END SNIPPET: init-vector
            }
        }, new RouteBuilder() {
            public void configure() throws Exception {
                // START SNIPPET: inline-init-vector
                KeyGenerator generator = KeyGenerator.getInstance("DES");
                byte[] initializationVector = new byte[] { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07 };
                SecretKey key = generator.generateKey();

                CryptoDataFormat cryptoFormat = new CryptoDataFormat("DES/CBC/PKCS5Padding", key);
                cryptoFormat.setInitVector(initializationVector);
                cryptoFormat.setShouldInlineInitializationVector(true);
                CryptoDataFormat decryptFormat = new CryptoDataFormat("DES/CBC/PKCS5Padding", key);
                decryptFormat.setShouldInlineInitializationVector(true);

                from("direct:inline")
                        .marshal(cryptoFormat)
                        .to("mock:encrypted")
                        .unmarshal(decryptFormat)
                        .to("mock:unencrypted");
                // END SNIPPET: inline-init-vector
            }
        }, new RouteBuilder() {
            public void configure() throws Exception {
                // START SNIPPET: hmac
                KeyGenerator generator = KeyGenerator.getInstance("DES");

                CryptoDataFormat cryptoFormat = new CryptoDataFormat("DES", generator.generateKey());
                cryptoFormat.setShouldAppendHMAC(true);

                from("direct:hmac")
                        .marshal(cryptoFormat)
                        .to("mock:encrypted")
                        .unmarshal(cryptoFormat)
                        .to("mock:unencrypted");
                // END SNIPPET: hmac
            }
        }, new RouteBuilder() {
            public void configure() throws Exception {
                // START SNIPPET: hmac-algorithm
                KeyGenerator generator = KeyGenerator.getInstance("DES");

                CryptoDataFormat cryptoFormat = new CryptoDataFormat("DES", generator.generateKey());
                cryptoFormat.setShouldAppendHMAC(true);
                cryptoFormat.setMacAlgorithm("HmacMD5");

                from("direct:hmac-algorithm")
                        .marshal(cryptoFormat)
                        .to("mock:encrypted")
                        .unmarshal(cryptoFormat)
                        .to("mock:unencrypted");
                // END SNIPPET: hmac-algorithm
            }
        }, new RouteBuilder() {
            public void configure() throws Exception {
                // START SNIPPET: hmac-sha256-algorithm
                KeyGenerator generator = KeyGenerator.getInstance("DES");

                CryptoDataFormat cryptoFormat = new CryptoDataFormat("DES", generator.generateKey());
                cryptoFormat.setShouldAppendHMAC(true);
                cryptoFormat.setMacAlgorithm("HmacSHA256");

                from("direct:hmac-sha-256-algorithm")
                        .marshal(cryptoFormat)
                        .to("mock:encrypted")
                        .unmarshal(cryptoFormat)
                        .to("mock:unencrypted");
                // END SNIPPET: hmac-sha256-algorithm
            }
        }, new RouteBuilder() {
            public void configure() {
                // START SNIPPET: key-in-header
                CryptoDataFormat cryptoFormat = new CryptoDataFormat("DES", null);
                /**
                 * Note: the header containing the key should be cleared after marshalling to stop it from leaking by
                 * accident and potentially being compromised. The processor version below is arguably better as the key
                 * is left in the header when you use the DSL leaks the fact that camel encryption was used.
                 */
                from("direct:key-in-header-encrypt")
                        .marshal(cryptoFormat)
                        .removeHeader(CryptoDataFormat.KEY)
                        .to("mock:encrypted");

                from("direct:key-in-header-decrypt").unmarshal(cryptoFormat).process(new Processor() {
                    public void process(Exchange exchange) {
                        exchange.getIn().getHeaders().remove(CryptoDataFormat.KEY);
                        exchange.getMessage().copyFrom(exchange.getIn());
                    }
                }).to("mock:unencrypted");
                // END SNIPPET: key-in-header
            }
        }, new RouteBuilder() {
            public void configure() throws Exception {
                // START SNIPPET: 3DES-ECB
                KeyGenerator generator = KeyGenerator.getInstance("DESede");

                CryptoDataFormat cryptoFormat = new CryptoDataFormat("DESede/ECB/PKCS5Padding", generator.generateKey());

                from("direct:3des-ecb-encryption")
                        .marshal(cryptoFormat)
                        .to("mock:encrypted")
                        .unmarshal(cryptoFormat)
                        .to("mock:unencrypted");
                // END SNIPPET: 3DES-ECB
            }
        }, new RouteBuilder() {
            public void configure() throws Exception {
                // START SNIPPET: 3DES-CBC
                KeyGenerator generator = KeyGenerator.getInstance("DES");
                byte[] iv = new byte[8];
                SecureRandom random = new SecureRandom();
                random.nextBytes(iv);
                Key key = generator.generateKey();

                CryptoDataFormat encCryptoFormat = new CryptoDataFormat("DES/CBC/PKCS5Padding", key);
                encCryptoFormat.setInitVector(iv);
                encCryptoFormat.setShouldInlineInitializationVector(true);

                CryptoDataFormat decCryptoFormat = new CryptoDataFormat("DES/CBC/PKCS5Padding", key);
                decCryptoFormat.setShouldInlineInitializationVector(true);

                from("direct:3des-cbc-encryption")
                        .marshal(encCryptoFormat)
                        .to("mock:encrypted")
                        .unmarshal(decCryptoFormat)
                        .to("mock:unencrypted");
                // END SNIPPET: 3DES-CBC
            }
        }, new RouteBuilder() {
            public void configure() throws Exception {
                // START SNIPPET: AES-128-ECB
                KeyGenerator generator = KeyGenerator.getInstance("AES");

                CryptoDataFormat cryptoFormat = new CryptoDataFormat("AES/ECB/PKCS5Padding", generator.generateKey());

                from("direct:aes-128-ecb-encryption")
                        .marshal(cryptoFormat)
                        .to("mock:encrypted")
                        .unmarshal(cryptoFormat)
                        .to("mock:unencrypted");
                // END SNIPPET: AES-128-ECB
            }
        }, new RouteBuilder() {
            public void configure() throws Exception {
                KeyGenerator generator = KeyGenerator.getInstance("AES");
                generator.init(128);

                SecureRandom random = new SecureRandom();
                byte[] iv = new byte[12];
                random.nextBytes(iv);

                GCMParameterSpec paramSpec = new GCMParameterSpec(128, iv);

                CryptoDataFormat cryptoFormat = new CryptoDataFormat("AES/GCM/NoPadding", generator.generateKey());
                cryptoFormat.setAlgorithmParameterSpec(paramSpec);

                from("direct:aes-gcm-encryption")
                        .marshal(cryptoFormat)
                        .to("mock:encrypted")
                        .unmarshal(cryptoFormat)
                        .to("mock:unencrypted");
            }
        }, new RouteBuilder() {
            public void configure() throws Exception {
                KeyGenerator generator = KeyGenerator.getInstance("AES");
                generator.init(128);

                SecureRandom random = new SecureRandom();
                byte[] iv = new byte[12];
                random.nextBytes(iv);

                GCMParameterSpec paramSpec = new GCMParameterSpec(128, iv);

                CryptoDataFormat cryptoFormat = new CryptoDataFormat("AES/GCM/NoPadding", generator.generateKey());
                cryptoFormat.setInitVector(iv);
                cryptoFormat.setShouldInlineInitializationVector(true);
                cryptoFormat.setAlgorithmParameterSpec(paramSpec);

                from("direct:inline-aes-gcm-encryption")
                        .marshal(cryptoFormat)
                        .to("mock:encrypted")
                        .unmarshal(cryptoFormat)
                        .to("mock:unencrypted");
            }
        }, new RouteBuilder() {
            public void configure() throws Exception {
                KeyGenerator generator = KeyGenerator.getInstance("DES");

                byte[] iv = new byte[8];
                SecureRandom random = new SecureRandom();
                random.nextBytes(iv);

                CryptoDataFormat cryptoFormat = new CryptoDataFormat();
                cryptoFormat.setKey(generator.generateKey());
                cryptoFormat.setInitVector(iv);
                // cryptoFormat.setAlgorithm("DES/CBC/PKCS5Padding");

                from("direct:no-algorithm")
                        .marshal(cryptoFormat)
                        .to("mock:encrypted")
                        .unmarshal(cryptoFormat)
                        .to("mock:unencrypted");
            }
        } };
    }

    private void awaitAndAssert(MockEndpoint mock) throws InterruptedException {
        mock.assertIsSatisfied();
    }

    public MockEndpoint setupExpectations(CamelContext context, int expected, String mock) {
        MockEndpoint mockEp = context.getEndpoint(mock, MockEndpoint.class);
        mockEp.expectedMessageCount(expected);
        return mockEp;
    }

    public static boolean checkUnrestrictedPoliciesInstalled() {
        try {
            byte[] data = { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07 };

            SecretKey key192 = new SecretKeySpec(
                    new byte[] {
                            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
                            0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
                            0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17 },
                    "AES");
            Cipher c = Cipher.getInstance("AES");
            c.init(Cipher.ENCRYPT_MODE, key192);
            c.doFinal(data);
            return true;
        } catch (Exception e) {
            //
        }
        return false;
    }

}
