/*
 *  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.server.core.kerberos;


import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import org.apache.directory.api.asn1.EncoderException;
import org.apache.directory.api.ldap.model.constants.Loggers;
import org.apache.directory.api.ldap.model.constants.SchemaConstants;
import org.apache.directory.api.ldap.model.entry.Attribute;
import org.apache.directory.api.ldap.model.entry.DefaultAttribute;
import org.apache.directory.api.ldap.model.entry.DefaultModification;
import org.apache.directory.api.ldap.model.entry.Entry;
import org.apache.directory.api.ldap.model.entry.Modification;
import org.apache.directory.api.ldap.model.entry.ModificationOperation;
import org.apache.directory.api.ldap.model.entry.Value;
import org.apache.directory.api.ldap.model.exception.LdapAuthenticationException;
import org.apache.directory.api.ldap.model.exception.LdapException;
import org.apache.directory.api.ldap.model.name.Dn;
import org.apache.directory.api.ldap.model.schema.AttributeType;
import org.apache.directory.api.util.Strings;
import org.apache.directory.server.core.api.DirectoryService;
import org.apache.directory.server.core.api.entry.ClonedServerEntry;
import org.apache.directory.server.core.api.interceptor.BaseInterceptor;
import org.apache.directory.server.core.api.interceptor.Interceptor;
import org.apache.directory.server.core.api.interceptor.context.AddOperationContext;
import org.apache.directory.server.core.api.interceptor.context.LookupOperationContext;
import org.apache.directory.server.core.api.interceptor.context.ModifyOperationContext;
import org.apache.directory.server.i18n.I18n;
import org.apache.directory.server.kerberos.shared.crypto.encryption.KerberosKeyFactory;
import org.apache.directory.server.kerberos.shared.crypto.encryption.RandomKeyFactory;
import org.apache.directory.shared.kerberos.KerberosAttribute;
import org.apache.directory.shared.kerberos.codec.types.EncryptionType;
import org.apache.directory.shared.kerberos.components.EncryptionKey;
import org.apache.directory.shared.kerberos.exceptions.KerberosException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;


/**
 * An {@link Interceptor} that creates symmetric Kerberos keys for users.  When a
 * 'userPassword' is added or modified, the 'userPassword' and 'krb5PrincipalName'
 * are used to derive Kerberos keys.  If the 'userPassword' is the special keyword
 * 'randomKey', a random key is generated and used as the Kerberos key.
 *
 * @author <a href="mailto:dev@directory.apache.org">Apache Directory Project</a>
 */
public class KeyDerivationInterceptor extends BaseInterceptor
{
    /** The log for this class. */
    private static final Logger LOG = LoggerFactory.getLogger( KeyDerivationInterceptor.class );
    private static final Logger LOG_KRB = LoggerFactory.getLogger( Loggers.KERBEROS_LOG.getName() );

    /** The service name. */
    private static final String NAME = "keyDerivationService";

    /** The krb5Key attribute type */
    private AttributeType krb5KeyAT;

    /** The krb5PrincipalName attribute type */
    private AttributeType krb5PrincipalNameAT;

    /** The krb5KeyVersionNumber attribute type */
    private AttributeType krb5KeyVersionNumberAT;

    /** The userPassword attribute tType */
    private AttributeType userPasswordAT;


    /**
     * Creates an instance of a KeyDerivationInterceptor.
     */
    public KeyDerivationInterceptor()
    {
        super( NAME );
    }


    /**
     * {@inheritDoc}
     */
    @Override
    public void init( DirectoryService directoryService ) throws LdapException
    {
        super.init( directoryService );

        krb5KeyAT = schemaManager.lookupAttributeTypeRegistry( KerberosAttribute.KRB5_KEY_AT );
        krb5PrincipalNameAT = schemaManager.lookupAttributeTypeRegistry( KerberosAttribute.KRB5_PRINCIPAL_NAME_AT );
        krb5KeyVersionNumberAT = schemaManager
            .lookupAttributeTypeRegistry( KerberosAttribute.KRB5_KEY_VERSION_NUMBER_AT );
        userPasswordAT = schemaManager
            .lookupAttributeTypeRegistry( SchemaConstants.USER_PASSWORD_AT );

        LOG_KRB.info( "KeyDerivation Interceptor initialized" );
    }


    /**
     * Intercepts the addition of the 'userPassword' and 'krb5PrincipalName' attributes.
     * Uses the 'userPassword' and 'krb5PrincipalName' attributes to derive Kerberos keys 
     * for the principal.  If the 'userPassword' is the special keyword 'randomKey', set 
     * random keys for the principal.  Set the key version number (kvno) to '0'.
     */
    @Override
    public void add( AddOperationContext addContext ) throws LdapException
    {
        // Bypass the replication events
        if ( addContext.isReplEvent() )
        {
            next( addContext );

            return;
        }

        Dn normName = addContext.getDn();

        Entry entry = addContext.getEntry();

        if ( ( entry.get( userPasswordAT ) != null ) && ( entry.get( krb5PrincipalNameAT ) != null ) )
        {
            LOG.debug( "Adding the entry '{}' for Dn '{}'.", entry, normName.getName() );

            // Get the entry's password. We will use the first one.
            Value userPassword = entry.get( userPasswordAT ).get();
            String strUserPassword = Strings.utf8ToString( userPassword.getBytes() );

            String principalName = entry.get( krb5PrincipalNameAT ).getString();

            if ( LOG.isDebugEnabled() )
            {
                LOG.debug( "Got principal '{}'.", principalName );
            }
            
            if ( LOG_KRB.isDebugEnabled() )
            {
                LOG_KRB.debug( "Got principal '{}'", principalName );
            }

            Map<EncryptionType, EncryptionKey> keys = generateKeys( principalName, strUserPassword );

            // Set the KVNO to 0 as it's a new entry
            entry.put( krb5KeyVersionNumberAT, "0" );

            Attribute keyAttribute = getKeyAttribute( keys );
            entry.put( keyAttribute );

            if ( LOG.isDebugEnabled() )
            {
                LOG.debug( "Adding modified entry '{}' for Dn '{}'.", entry, normName
                    .getName() );
            }
            
            if ( LOG_KRB.isDebugEnabled() )
            {
                LOG_KRB.debug( "Adding modified entry '{}' for Dn '{}'.", entry, normName
                    .getName() );
            }
        }

        next( addContext );
    }


    /**
     * Intercept the modification of the 'userPassword' attribute.  Perform a lookup to check for an
     * existing principal name and key version number (kvno).  If a 'krb5PrincipalName' is not in
     * the modify request, attempt to use an existing 'krb5PrincipalName' attribute.  If a kvno
     * exists, increment the kvno; otherwise, set the kvno to '0'.
     *
     * If both a 'userPassword' and 'krb5PrincipalName' can be found, use the 'userPassword' and
     * 'krb5PrincipalName' attributes to derive Kerberos keys for the principal.
     *
     * If the 'userPassword' is the special keyword 'randomKey', set random keys for the principal.
     */
    @Override
    public void modify( ModifyOperationContext modContext ) throws LdapException
    {
        // bypass replication events
        if ( modContext.isReplEvent() )
        {
            next( modContext );
            return;
        }

        ModifySubContext subContext = new ModifySubContext();

        detectPasswordModification( modContext, subContext );

        if ( subContext.getUserPassword() != null )
        {
            lookupPrincipalAttributes( modContext, subContext );
        }

        if ( subContext.isPrincipal() && subContext.hasValues() )
        {
            deriveKeys( modContext, subContext );
        }

        next( modContext );
    }


    /**
     * Detect password modification by checking the modify request for the 'userPassword'.  Additionally,
     * check to see if a 'krb5PrincipalName' was provided.
     *
     * @param modContext The original ModifyContext
     * @param subContext The modification container
     * @throws LdapException If we get an exception
     */
    private void detectPasswordModification( ModifyOperationContext modContext, ModifySubContext subContext )
        throws LdapException
    {
        List<Modification> mods = modContext.getModItems();

        String operation = null;

        // Loop over attributes being modified to pick out 'userPassword' and 'krb5PrincipalName'.
        for ( Modification mod : mods )
        {
            if ( LOG.isDebugEnabled() )
            {
                switch ( mod.getOperation() )
                {
                    case ADD_ATTRIBUTE:
                        operation = "Adding";
                        break;

                    case REMOVE_ATTRIBUTE:
                        operation = "Removing";
                        break;

                    case REPLACE_ATTRIBUTE:
                        operation = "Replacing";
                        break;

                    default:
                        throw new IllegalArgumentException( "Unexpected modify operation " + mod.getOperation() );
                }
            }

            Attribute attr = mod.getAttribute();

            if ( userPasswordAT.equals( attr.getAttributeType() ) )
            {
                Value firstValue = attr.get();
                String password = null;

                if ( firstValue.isHumanReadable() )
                {
                    password = firstValue.getString();
                    LOG.debug( "{} Attribute id : 'userPassword',  Values : [ '{}' ]", operation, password );
                    LOG_KRB.debug( "{} Attribute id : 'userPassword',  Values : [ '{}' ]", operation, password );
                }
                else
                {
                    password = Strings.utf8ToString( firstValue.getBytes() );
                }

                subContext.setUserPassword( password );
            }

            if ( krb5PrincipalNameAT.equals( attr.getAttributeType() ) )
            {
                subContext.setPrincipalName( attr.getString() );
                LOG.debug( "Got principal '{}'.", subContext.getPrincipalName() );
                LOG_KRB.debug( "Got principal '{}'.", subContext.getPrincipalName() );
            }
        }
    }


    /**
     * Lookup the principal's attributes that are relevant to executing key derivation.
     *
     * @param modContext The original ModifyContext
     * @param subContext The modification container
     * @throws LdapException If we get an exception
     */
    private void lookupPrincipalAttributes( ModifyOperationContext modContext, ModifySubContext subContext )
        throws LdapException
    {
        Dn principalDn = modContext.getDn();

        LookupOperationContext lookupContext = modContext.newLookupContext( principalDn,
            SchemaConstants.OBJECT_CLASS_AT,
            KerberosAttribute.KRB5_PRINCIPAL_NAME_AT,
            KerberosAttribute.KRB5_KEY_VERSION_NUMBER_AT );
        lookupContext.setPartition( modContext.getPartition() );
        lookupContext.setTransaction( modContext.getTransaction() );

        Entry userEntry = directoryService.getPartitionNexus().lookup( lookupContext );

        if ( userEntry == null )
        {
            throw new LdapAuthenticationException( I18n.err( I18n.ERR_512, principalDn ) );
        }

        if ( !( ( ClonedServerEntry ) userEntry ).getOriginalEntry().contains(
            directoryService.getAtProvider().getObjectClass(), SchemaConstants.KRB5_PRINCIPAL_OC ) )
        {
            return;
        }
        else
        {
            subContext.isPrincipal( true );
            LOG.debug( "Dn {} is a Kerberos principal.  Will attempt key derivation.", principalDn.getName() );
            LOG_KRB.debug( "Dn {} is a Kerberos principal.  Will attempt key derivation.", principalDn.getName() );
        }

        if ( subContext.getPrincipalName() == null )
        {
            Attribute principalAttribute = ( ( ClonedServerEntry ) userEntry ).getOriginalEntry().get(
                krb5PrincipalNameAT );
            String principalName = principalAttribute.getString();
            subContext.setPrincipalName( principalName );
            LOG.debug( "Found principal '{}' from lookup.", principalName );
            LOG_KRB.debug( "Found principal '{}' from lookup.", principalName );
        }

        Attribute keyVersionNumberAttr = ( ( ClonedServerEntry ) userEntry ).getOriginalEntry().get(
            krb5KeyVersionNumberAT );

        // Set the KVNO to 0 if it's a password creation,
        // otherwise increment it.
        if ( keyVersionNumberAttr == null )
        {
            subContext.setNewKeyVersionNumber( 0 );
            LOG.debug( "Key version number was null, setting to 0." );
            LOG_KRB.debug( "Key version number was null, setting to 0." );
        }
        else
        {
            int oldKeyVersionNumber = Integer.parseInt( keyVersionNumberAttr.getString() );
            int newKeyVersionNumber = oldKeyVersionNumber + 1;
            subContext.setNewKeyVersionNumber( newKeyVersionNumber );
            LOG.debug( "Found key version number '{}', setting to '{}'.", oldKeyVersionNumber, newKeyVersionNumber );
            LOG_KRB.debug( "Found key version number '{}', setting to '{}'.", oldKeyVersionNumber, newKeyVersionNumber );
        }
    }


    /**
     * Use the 'userPassword' and 'krb5PrincipalName' attributes to derive Kerberos keys for the principal.
     *
     * If the 'userPassword' is the special keyword 'randomKey', set random keys for the principal.
     *
     * @param modContext The original ModifyContext
     * @param subContext The modification container
     */
    void deriveKeys( ModifyOperationContext modContext, ModifySubContext subContext ) throws LdapException
    {
        List<Modification> mods = modContext.getModItems();

        String principalName = subContext.getPrincipalName();
        String userPassword = subContext.getUserPassword();
        int kvno = subContext.getNewKeyVersionNumber();

        LOG.debug( "Got principal '{}' with userPassword '{}'.", principalName, userPassword );
        LOG_KRB.debug( "Got principal '{}' with userPassword '{}'.", principalName, userPassword );

        Map<EncryptionType, EncryptionKey> keys = generateKeys( principalName, userPassword );

        List<Modification> newModsList = new ArrayList<>();

        // Make sure we preserve any other modification items.
        for ( Modification mod : mods )
        {
            newModsList.add( mod );
        }

        // Add our modification items.
        Modification krb5PrincipalName =
            new DefaultModification(
                ModificationOperation.REPLACE_ATTRIBUTE,
                new DefaultAttribute(
                    krb5PrincipalNameAT,
                    principalName ) );
        newModsList.add( krb5PrincipalName );

        Modification krb5KeyVersionNumber =
            new DefaultModification(
                ModificationOperation.REPLACE_ATTRIBUTE,
                new DefaultAttribute(
                    krb5KeyVersionNumberAT,
                    Integer.toString( kvno ) ) );

        newModsList.add( krb5KeyVersionNumber );

        Attribute attribute = getKeyAttribute( keys );
        newModsList.add( new DefaultModification( ModificationOperation.REPLACE_ATTRIBUTE, attribute ) );

        LOG.debug( "Added two modifications to the current request : {} and {}", krb5PrincipalName,
            krb5KeyVersionNumber );
        LOG_KRB.debug( "Added two modifications to the current request : {} and {}", krb5PrincipalName,
            krb5KeyVersionNumber );

        modContext.setModItems( newModsList );
    }


    /**
     * Create the KRB5_KEY attribute with all the associated keys.
     *  
     * @param keys The keys to inject in the attribute
     * @return The create attribute
     * @throws LdapException If we had an error while adding a key in the attribute
     */
    private Attribute getKeyAttribute( Map<EncryptionType, EncryptionKey> keys )
        throws LdapException
    {
        Attribute keyAttribute = new DefaultAttribute( krb5KeyAT );

        for ( EncryptionKey encryptionKey : keys.values() )
        {
            try
            {
                ByteBuffer buffer = ByteBuffer.allocate( encryptionKey.computeLength() );
                encryptionKey.encode( buffer );
                keyAttribute.add( buffer.array() );
            }
            catch ( EncoderException ioe )
            {
                LOG.error( I18n.err( I18n.ERR_122 ), ioe );
                LOG_KRB.error( I18n.err( I18n.ERR_122 ), ioe );
            }
        }

        return keyAttribute;
    }


    /**
     * Generate the keys.
     * 
     * @param principalName The Principal
     * @param userPassword Its password
     * @return A Map of keys
     */
    private Map<EncryptionType, EncryptionKey> generateKeys( String principalName, String userPassword )
    {
        if ( userPassword.equalsIgnoreCase( "randomKey" ) )
        {
            // Generate random key.
            try
            {
                return RandomKeyFactory.getRandomKeys();
            }
            catch ( KerberosException ke )
            {
                LOG.debug( ke.getLocalizedMessage(), ke );
                LOG_KRB.debug( ke.getLocalizedMessage(), ke );

                return null;
            }
        }
        else
        {
            // Derive key based on password and principal name.
            return KerberosKeyFactory.getKerberosKeys( principalName, userPassword );
        }
    }

    /**
     * A ModifyContext used to store the changes made to the original context. This
     * is used while processing a ModifyOperation and will be injected in the
     * original ModifyContext.
     */
    static class ModifySubContext
    {
        /** Tells if this is a principal */
        private boolean isPrincipal = false;

        /** The Principal name */
        private String principalName;

        /** The User password */
        private String userPassword;

        /** The Key version */
        private int newKeyVersionNumber = -1;


        boolean isPrincipal()
        {
            return isPrincipal;
        }


        void isPrincipal( boolean isPrincipal )
        {
            this.isPrincipal = isPrincipal;
        }


        String getPrincipalName()
        {
            return principalName;
        }


        void setPrincipalName( String principalName )
        {
            this.principalName = principalName;
        }


        String getUserPassword()
        {
            return userPassword;
        }


        void setUserPassword( String userPassword )
        {
            this.userPassword = userPassword;
        }


        int getNewKeyVersionNumber()
        {
            return newKeyVersionNumber;
        }


        void setNewKeyVersionNumber( int newKeyVersionNumber )
        {
            this.newKeyVersionNumber = newKeyVersionNumber;
        }


        boolean hasValues()
        {
            return ( userPassword != null ) && ( principalName != null ) && ( newKeyVersionNumber > -1 );
        }
    }
}
