/*
 *  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.directory.shared.client.api;


import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.instanceOf;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.InetAddress;
import java.security.KeyPair;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.cert.CertPathBuilderException;
import java.security.cert.CertPathValidatorException;
import java.security.cert.CertPathValidatorException.BasicReason;
import java.security.cert.CertPathValidatorException.Reason;
import java.security.cert.Certificate;
import java.security.cert.CertificateExpiredException;
import java.security.cert.CertificateNotYetValidException;
import java.security.cert.X509Certificate;
import java.util.Date;

import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;

import org.apache.directory.api.ldap.model.entry.DefaultEntry;
import org.apache.directory.api.ldap.model.entry.Entry;
import org.apache.directory.api.ldap.model.exception.LdapException;
import org.apache.directory.api.ldap.model.exception.LdapTlsHandshakeException;
import org.apache.directory.api.ldap.model.exception.LdapTlsHandshakeFailCause.LdapApiReason;
import org.apache.directory.ldap.client.api.LdapConnectionConfig;
import org.apache.directory.ldap.client.api.LdapNetworkConnection;
import org.apache.directory.ldap.client.api.NoVerificationTrustManager;
import org.apache.directory.server.annotations.CreateLdapServer;
import org.apache.directory.server.core.annotations.CreateDS;
import org.apache.directory.server.core.integ.AbstractLdapTestUnit;
import org.apache.directory.server.core.integ.ApacheDSTestExtension;
import org.apache.directory.server.core.security.TlsKeyGenerator;
import org.apache.directory.server.ldap.handlers.extended.StartTlsHandler;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;


/**
 * Test connections with SSL/StartTLS with various certificates.
 * 
 * @author <a href="mailto:dev@directory.apache.org">Apache Directory Project</a>
 */
@ExtendWith( { ApacheDSTestExtension.class } )
@CreateDS(allowAnonAccess = true, name = "KeyStoreIT-class")
public class CertificateValidationTest extends AbstractLdapTestUnit
{

    private static final String KEYSTORE_PW = "changeit";

    private static final String ROOT_CA_KEYSTORE_PATH = "target/test-classes/root-ca-keystore.ks";
    private static KeyStore ROOT_CA_KEYSTORE;

    private static final String VALID_KEYSTORE_PATH = "target/test-classes/valid-keystore.ks";

    private static final String EXPIRED_KEYSTORE_PATH = "target/test-classes/expired-keystore.ks";

    private static final String NOT_YET_VALID_KEYSTORE_PATH = "target/test-classes/not-yet-valid-keystore.ks";

    private static final String SMALL_KEYSIZE_KEYSTORE_PATH = "target/test-classes/small-keysize-keystore.ks";


    @BeforeAll
    public static void installKeyStoreWithCertificate() throws Exception
    {
        String hostName = InetAddress.getLocalHost().getHostName();
        String issuerDn = TlsKeyGenerator.CERTIFICATE_PRINCIPAL_DN;
        String subjectDn = "CN=" + hostName;
        Date startDate = new Date();
        Date expiryDate = new Date( System.currentTimeMillis() + TlsKeyGenerator.YEAR_MILLIS );
        String keyAlgo = "RSA";
        int keySize = 2048;

        // generate root CA, self-signed
        String rootCaSubjectDn = issuerDn;
        ROOT_CA_KEYSTORE = createKeyStore( rootCaSubjectDn, issuerDn, startDate, expiryDate, keyAlgo, keySize, null,
            true, ROOT_CA_KEYSTORE_PATH );
        PrivateKey rootCaPrivateKey = ( PrivateKey ) ROOT_CA_KEYSTORE.getKey( "apacheds", KEYSTORE_PW.toCharArray() );

        // generate a valid certificate, signed by root CA
        createKeyStore( subjectDn, issuerDn, startDate, expiryDate, keyAlgo, keySize, rootCaPrivateKey,
            false, VALID_KEYSTORE_PATH );

        // generate an expired certificate, signed by root CA
        Date expiredStartDate = new Date( System.currentTimeMillis() - TlsKeyGenerator.YEAR_MILLIS );
        Date expiredExpiryDate = new Date( System.currentTimeMillis() - TlsKeyGenerator.YEAR_MILLIS / 365 );
        createKeyStore( subjectDn, issuerDn, expiredStartDate, expiredExpiryDate, keyAlgo, keySize,
            rootCaPrivateKey, false, EXPIRED_KEYSTORE_PATH );

        // generate a not yet valid certificate, signed by root CA
        Date notYetValidStartDate = new Date( System.currentTimeMillis() + TlsKeyGenerator.YEAR_MILLIS / 365 );
        Date notYetValidExpiryDate = new Date( System.currentTimeMillis() + TlsKeyGenerator.YEAR_MILLIS );
        createKeyStore( subjectDn, issuerDn, notYetValidStartDate, notYetValidExpiryDate, keyAlgo, keySize,
            rootCaPrivateKey, false, NOT_YET_VALID_KEYSTORE_PATH );

        // generate a certificate with small key size, signed by root CA
        int smallKeySize = 512;
        createKeyStore( subjectDn, issuerDn, startDate, expiryDate, keyAlgo, smallKeySize,
            rootCaPrivateKey, false, SMALL_KEYSIZE_KEYSTORE_PATH );

        // TODO signature does not match if root private key is null
    }


    private static KeyStore createKeyStore( String subjectDn, String issuerDn, Date startDate, Date expiryDate,
        String keyAlgo, int keySize, PrivateKey optionalSigningKey, boolean isCA, String keystorePath )
        throws Exception
    {
        File goodKeyStoreFile = new File( keystorePath );
        if ( goodKeyStoreFile.exists() )
        {
            goodKeyStoreFile.delete();
        }
        Entry entry = new DefaultEntry();
        TlsKeyGenerator.addKeyPair( entry, issuerDn, subjectDn, startDate, expiryDate, keyAlgo, keySize,
            optionalSigningKey, isCA );
        KeyPair keyPair = TlsKeyGenerator.getKeyPair( entry );
        X509Certificate cert = TlsKeyGenerator.getCertificate( entry );
        //System.out.println( cert );

        KeyStore keyStore = KeyStore.getInstance( KeyStore.getDefaultType() );
        keyStore.load( null, null );
        keyStore.setCertificateEntry( "apacheds", cert );
        keyStore.setKeyEntry( "apacheds", keyPair.getPrivate(), KEYSTORE_PW.toCharArray(), new Certificate[]
            { cert } );
        try ( FileOutputStream out = new FileOutputStream( goodKeyStoreFile ) )
        {
            keyStore.store( out, KEYSTORE_PW.toCharArray() );
        }
        return keyStore;
    }


    @CreateLdapServer(keyStore = VALID_KEYSTORE_PATH, certificatePassword = KEYSTORE_PW)
    @Test
    public void testLdaps_Valid_NoVerificationTrustManager() throws Exception
    {
        LdapConnectionConfig config = ldapsConnectionConfig();
        config.setTrustManagers( noVerificationTrustManagers() );
        connectOk( config );
    }


    @CreateLdapServer(keyStore = VALID_KEYSTORE_PATH, certificatePassword = KEYSTORE_PW, extendedOpHandlers = StartTlsHandler.class)
    @Test
    public void testStartTls_Valid_NoVerificationTrustManager() throws Exception
    {
        LdapConnectionConfig config = startTlsConnectionConfig();
        config.setTrustManagers( noVerificationTrustManagers() );
        connectOk( config );
    }


    @CreateLdapServer(keyStore = ROOT_CA_KEYSTORE_PATH, certificatePassword = KEYSTORE_PW)
    @Test
    public void testLdaps_SelfSigned_JvmDefaultTrustManager() throws Exception
    {
        LdapConnectionConfig config = ldapsConnectionConfig();
        config.setTrustManagers( jvmDefaultTrustManagers() );
        connectAndExpectTlsHandshakeException( config, CertPathBuilderException.class,
            LdapApiReason.NO_VALID_CERTIFICATION_PATH, "Failed to build certification path",
            "unable to find valid certification path to requested target" );
    }


    @CreateLdapServer(keyStore = ROOT_CA_KEYSTORE_PATH, certificatePassword = KEYSTORE_PW, extendedOpHandlers = StartTlsHandler.class)
    @Test
    public void testStartTls_SelfSigned_JvmDefaultTrustManager() throws Exception
    {
        LdapConnectionConfig config = startTlsConnectionConfig();
        config.setTrustManagers( jvmDefaultTrustManagers() );
        connectAndExpectTlsHandshakeException( config, CertPathBuilderException.class,
            LdapApiReason.NO_VALID_CERTIFICATION_PATH, "Failed to build certification path",
            "unable to find valid certification path to requested target" );
    }


    @CreateLdapServer(keyStore = VALID_KEYSTORE_PATH, certificatePassword = KEYSTORE_PW)
    @Test
    public void testLdaps_Valid_JvmDefaultTrustManager() throws Exception
    {
        LdapConnectionConfig config = ldapsConnectionConfig();
        config.setTrustManagers( jvmDefaultTrustManagers() );
        connectAndExpectTlsHandshakeException( config, CertPathBuilderException.class,
            LdapApiReason.NO_VALID_CERTIFICATION_PATH, "Failed to build certification path",
            "unable to find valid certification path to requested target" );
    }


    @CreateLdapServer(keyStore = VALID_KEYSTORE_PATH, certificatePassword = KEYSTORE_PW, extendedOpHandlers = StartTlsHandler.class)
    @Test
    public void testStartTls_Valid_JvmDefaultTrustManager() throws Exception
    {
        LdapConnectionConfig config = startTlsConnectionConfig();
        config.setTrustManagers( jvmDefaultTrustManagers() );
        connectAndExpectTlsHandshakeException( config, CertPathBuilderException.class,
            LdapApiReason.NO_VALID_CERTIFICATION_PATH, "Failed to build certification path",
            "unable to find valid certification path to requested target" );
    }


    @CreateLdapServer(keyStore = VALID_KEYSTORE_PATH, certificatePassword = KEYSTORE_PW)
    @Test
    public void testLdaps_Valid_RootCaTrustManager() throws Exception
    {
        LdapConnectionConfig config = ldapsConnectionConfig();
        config.setTrustManagers( getCustomTrustManager( ROOT_CA_KEYSTORE ) );
        connectOk( config );
    }


    @CreateLdapServer(keyStore = VALID_KEYSTORE_PATH, certificatePassword = KEYSTORE_PW, extendedOpHandlers = StartTlsHandler.class)
    @Test
    public void testStartTls_Valid_RootCaTrustManager() throws Exception
    {
        LdapConnectionConfig config = startTlsConnectionConfig();
        config.setTrustManagers( getCustomTrustManager( ROOT_CA_KEYSTORE ) );
        connectOk( config );
    }


    @CreateLdapServer(keyStore = EXPIRED_KEYSTORE_PATH, certificatePassword = KEYSTORE_PW)
    @Test
    public void testLdaps_Expired_RootCaTrustManager() throws Exception
    {
        LdapConnectionConfig config = ldapsConnectionConfig();
        config.setTrustManagers( getCustomTrustManager( ROOT_CA_KEYSTORE ) );
        connectAndExpectTlsHandshakeException( config, CertificateExpiredException.class, BasicReason.EXPIRED,
            "Certificate expired", "NotAfter" );
    }


    @CreateLdapServer(keyStore = EXPIRED_KEYSTORE_PATH, certificatePassword = KEYSTORE_PW, extendedOpHandlers = StartTlsHandler.class)
    @Test
    public void testStartTls_Expired_RootCaTrustManager() throws Exception
    {
        LdapConnectionConfig config = startTlsConnectionConfig();
        config.setTrustManagers( getCustomTrustManager( ROOT_CA_KEYSTORE ) );
        connectAndExpectTlsHandshakeException( config, CertificateExpiredException.class, BasicReason.EXPIRED,
            "Certificate expired", "NotAfter" );
    }


    @CreateLdapServer(keyStore = NOT_YET_VALID_KEYSTORE_PATH, certificatePassword = KEYSTORE_PW)
    @Test
    public void testLdaps_NotYetValid_RootCaTrustManager() throws Exception
    {
        LdapConnectionConfig config = ldapsConnectionConfig();
        config.setTrustManagers( getCustomTrustManager( ROOT_CA_KEYSTORE ) );
        connectAndExpectTlsHandshakeException( config, CertificateNotYetValidException.class, BasicReason.NOT_YET_VALID,
            "Certificate not yet valid", "NotBefore" );
    }


    @CreateLdapServer(keyStore = NOT_YET_VALID_KEYSTORE_PATH, certificatePassword = KEYSTORE_PW, extendedOpHandlers = StartTlsHandler.class)
    @Test
    public void testStartTls_NotYetValid_RootCaTrustManager() throws Exception
    {
        LdapConnectionConfig config = startTlsConnectionConfig();
        config.setTrustManagers( getCustomTrustManager( ROOT_CA_KEYSTORE ) );
        connectAndExpectTlsHandshakeException( config, CertificateNotYetValidException.class, BasicReason.NOT_YET_VALID,
            "Certificate not yet valid", "NotBefore" );
    }


    @CreateLdapServer(keyStore = SMALL_KEYSIZE_KEYSTORE_PATH, certificatePassword = KEYSTORE_PW)
    @Test
    public void testLdaps_SmallKeySize_RootCaTrustManager() throws Exception
    {
        LdapConnectionConfig config = ldapsConnectionConfig();
        config.setTrustManagers( getCustomTrustManager( ROOT_CA_KEYSTORE ) );
        connectAndExpectTlsHandshakeException( config, CertPathValidatorException.class,
            BasicReason.ALGORITHM_CONSTRAINED, "Failed to verify certification path",
            "Algorithm constraints check failed on keysize limits" );
    }


    @CreateLdapServer(keyStore = SMALL_KEYSIZE_KEYSTORE_PATH, certificatePassword = KEYSTORE_PW, extendedOpHandlers = StartTlsHandler.class)
    @Test
    public void testStartTls_SmallKeySize_RootCaTrustManager() throws Exception
    {
        LdapConnectionConfig config = startTlsConnectionConfig();
        config.setTrustManagers( getCustomTrustManager( ROOT_CA_KEYSTORE ) );
        connectAndExpectTlsHandshakeException( config, CertPathValidatorException.class,
            BasicReason.ALGORITHM_CONSTRAINED, "Failed to verify certification path",
            "Algorithm constraints check failed on keysize limits" );
    }


    private void connectOk( LdapConnectionConfig config ) throws LdapException, IOException
    {
        assertTrue( getLdapServer().isStarted() );

        try ( LdapNetworkConnection conn = new LdapNetworkConnection( config ) )
        {
            if ( config.isUseTls() )
            {
                conn.startTls();
            }
            else if ( config.isUseSsl() )
            {
                conn.connect();
            }
            else
            {
                fail( "Either useTls or useSsl must be enabled" );
            }

            assertTrue( conn.isConnected() );
            assertTrue( conn.isSecured() );
        }
    }


    private void connectAndExpectTlsHandshakeException( LdapConnectionConfig config,
        Class<? extends Throwable> rootCauseExceptionClass, Reason reason,
        String reasonPhrase, String reasonMessage ) throws IOException
    {
        assertTrue( getLdapServer().isStarted() );

        try ( LdapNetworkConnection conn = new LdapNetworkConnection( config ) )
        {
            try
            {
                if ( config.isUseTls() )
                {
                    conn.startTls();
                }
                else if ( config.isUseSsl() )
                {
                    conn.connect();
                }
                else
                {
                    fail( "Either useTls or useSsl must be enabled" );
                }

                fail( "Expected exception" );
            }
            catch ( LdapException e )
            {
                assertThat( e, is( instanceOf( LdapTlsHandshakeException.class ) ) );
                LdapTlsHandshakeException lthse = ( LdapTlsHandshakeException ) e;
                assertThat( lthse.getFailCause().getRootCause(), instanceOf( rootCauseExceptionClass ) );
                assertThat( lthse.getFailCause().getReason(), equalTo( reason ) );
                assertThat( lthse.getFailCause().getReasonPhrase(), equalTo( reasonPhrase ) );
                assertThat( lthse.getMessage(), containsString( "ERR_04120_TLS_HANDSHAKE_ERROR" ) );
                assertThat( lthse.getMessage(), containsString( "The TLS handshake failed" ) );
                assertThat( lthse.getMessage(), containsString( "reason: " + reasonPhrase ) );
                assertThat( lthse.getMessage(), containsString( reasonMessage ) );
            }

            assertFalse( conn.isConnected() );
            assertFalse( conn.isSecured() );
        }
    }


    private LdapConnectionConfig startTlsConnectionConfig()
    {
        LdapConnectionConfig config = new LdapConnectionConfig();
        config.setTimeout( 1000 );
        config.setLdapHost( "localhost" );
        config.setLdapPort( getLdapServer().getPort() );
        config.setUseTls( true );
        return config;
    }


    private LdapConnectionConfig ldapsConnectionConfig()
    {
        LdapConnectionConfig config = new LdapConnectionConfig();
        config.setTimeout( 1000 );
        config.setLdapHost( "localhost" );
        config.setLdapPort( getLdapServer().getPortSSL() );
        config.setUseSsl( true );
        return config;
    }


    private X509TrustManager getCustomTrustManager( KeyStore trustStore )
        throws NoSuchAlgorithmException, KeyStoreException
    {
        TrustManagerFactory factory = TrustManagerFactory.getInstance( TrustManagerFactory
            .getDefaultAlgorithm() );
        factory.init( trustStore );
        TrustManager[] trustManagers = factory.getTrustManagers();
        TrustManager trustManager = trustManagers[0];
        return ( X509TrustManager ) trustManager;
    }


    private TrustManager[] jvmDefaultTrustManagers() throws NoSuchAlgorithmException, KeyStoreException
    {
        TrustManagerFactory factory = TrustManagerFactory.getInstance( TrustManagerFactory
            .getDefaultAlgorithm() );
        factory.init( ( KeyStore ) null );
        TrustManager[] defaultTrustManagers = factory.getTrustManagers();
        return defaultTrustManagers;
    }


    private TrustManager[] noVerificationTrustManagers()
    {
        return new X509TrustManager[]
            { new NoVerificationTrustManager() };
    }

}
