/*
 *   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.api.ldap.model.password;


import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Date;

import org.apache.directory.api.ldap.model.constants.LdapSecurityConstants;
import org.apache.directory.api.util.Base64;
import org.apache.directory.api.util.DateUtils;
import org.apache.directory.api.util.Strings;
import org.apache.directory.api.util.UnixCrypt;


/**
 * A utility class containing methods related to processing passwords.
 *
 * @author <a href="mailto:dev@directory.apache.org">Apache Directory Project</a>
 */
public class PasswordUtil
{

    /** The SHA1 hash length */
    public static final int SHA1_LENGTH = 20;

    /** The SHA256 hash length */
    public static final int SHA256_LENGTH = 32;

    /** The SHA384 hash length */
    public static final int SHA384_LENGTH = 48;

    /** The SHA512 hash length */
    public static final int SHA512_LENGTH = 64;

    /** The MD5 hash length */
    public static final int MD5_LENGTH = 16;


    /**
     * Get the algorithm from the stored password. 
     * It can be found on the beginning of the stored password, between 
     * curly brackets.
     * @param credentials the credentials of the user
     * @return the name of the algorithm to use
     */
    public static LdapSecurityConstants findAlgorithm( byte[] credentials )
    {
        if ( ( credentials == null ) || ( credentials.length == 0 ) )
        {
            return null;
        }

        if ( credentials[0] == '{' )
        {
            // get the algorithm
            int pos = 1;

            while ( pos < credentials.length )
            {
                if ( credentials[pos] == '}' )
                {
                    break;
                }

                pos++;
            }

            if ( pos < credentials.length )
            {
                if ( pos == 1 )
                {
                    // We don't have an algorithm : return the credentials as is
                    return null;
                }

                String algorithm = Strings.toLowerCase( new String( credentials, 1, pos - 1 ) );

                return LdapSecurityConstants.getAlgorithm( algorithm );
            }
            else
            {
                // We don't have an algorithm
                return null;
            }
        }
        else
        {
            // No '{algo}' part
            return null;
        }
    }


    /**
     * @see #createStoragePassword(byte[], LdapSecurityConstants)
     */
    public static byte[] createStoragePassword( String credentials, LdapSecurityConstants algorithm )
    {
        return createStoragePassword( Strings.getBytesUtf8( credentials ), algorithm );
    }


    /**
     * create a hashed password in a format that can be stored in the server.
     * If the specified algorithm requires a salt then a random salt of 8 byte size is used
     *  
     * @param credentials the plain text password
     * @param algorithm the hashing algorithm to be applied
     * @return the password after hashing with the given algorithm 
     */
    public static byte[] createStoragePassword( byte[] credentials, LdapSecurityConstants algorithm )
    {
        byte[] salt;

        switch ( algorithm )
        {
            case HASH_METHOD_SSHA:
            case HASH_METHOD_SSHA256:
            case HASH_METHOD_SSHA384:
            case HASH_METHOD_SSHA512:
            case HASH_METHOD_SMD5:
                salt = new byte[8]; // we use 8 byte salt always except for "crypt" which needs 2 byte salt
                new SecureRandom().nextBytes( salt );
                break;

            case HASH_METHOD_CRYPT:
                salt = new byte[2];
                SecureRandom sr = new SecureRandom();
                int i1 = sr.nextInt( 64 );
                int i2 = sr.nextInt( 64 );

                salt[0] = ( byte ) ( i1 < 12 ? ( i1 + '.' ) : i1 < 38 ? ( i1 + 'A' - 12 ) : ( i1 + 'a' - 38 ) );
                salt[1] = ( byte ) ( i2 < 12 ? ( i2 + '.' ) : i2 < 38 ? ( i2 + 'A' - 12 ) : ( i2 + 'a' - 38 ) );
                break;

            default:
                salt = null;
        }

        byte[] hashedPassword = encryptPassword( credentials, algorithm, salt );
        StringBuffer sb = new StringBuffer();

        if ( algorithm != null )
        {
            sb.append( '{' ).append( algorithm.getPrefix().toUpperCase() ).append( '}' );

            if ( algorithm == LdapSecurityConstants.HASH_METHOD_CRYPT )
            {
                sb.append( Strings.utf8ToString( salt ) );
                sb.append( Strings.utf8ToString( hashedPassword ) );
            }
            else if ( salt != null )
            {
                byte[] hashedPasswordWithSaltBytes = new byte[hashedPassword.length + salt.length];
                merge( hashedPasswordWithSaltBytes, hashedPassword, salt );
                sb.append( String.valueOf( Base64.encode( hashedPasswordWithSaltBytes ) ) );
            }
            else
            {
                sb.append( String.valueOf( Base64.encode( hashedPassword ) ) );
            }
        }
        else
        {
            sb.append( Strings.utf8ToString( hashedPassword ) );
        }

        return Strings.getBytesUtf8( sb.toString() );
    }


    /**
     * 
     * Compare the credentials.
     * We have at least 6 algorithms to encrypt the password :
     * <ul>
     * <li>- SHA</li>
     * <li>- SSHA (salted SHA)</li>
     * <li>- SHA-2(256, 384 and 512 and their salted versions)</li>
     * <li>- MD5</li>
     * <li>- SMD5 (slated MD5)</li>
     * <li>- crypt (unix crypt)</li>
     * <li>- plain text, ie no encryption.</li>
     * </ul>
     * <p>
     *  If we get an encrypted password, it is prefixed by the used algorithm, between
     *  brackets : {SSHA}password ...
     *  </p>
     *  If the password is using SSHA, SMD5 or crypt, some 'salt' is added to the password :
     *  <ul>
     *  <li>- length(password) - 20, starting at 21th position for SSHA</li>
     *  <li>- length(password) - 16, starting at 16th position for SMD5</li>
     *  <li>- length(password) - 2, starting at 3rd position for crypt</li>
     *  </ul>
     *  <p>
     *  For (S)SHA, SHA-256 and (S)MD5, we have to transform the password from Base64 encoded text
     *  to a byte[] before comparing the password with the stored one.
     *  </p>
     *  <p>
     *  For crypt, we only have to remove the salt.
     *  </p>
     *  <p>
     *  At the end, we use the digest() method for (S)SHA and (S)MD5, the crypt() method for
     *  the CRYPT algorithm and a straight comparison for PLAIN TEXT passwords.
     *  </p>
     *  <p>
     *  The stored password is always using the unsalted form, and is stored as a bytes array.
     *  </p>
     *
     * @param receivedCredentials the credentials provided by user
     * @param storedCredentials the credentials stored in the server
     * @return true if they are equal, false otherwise
     */
    public static boolean compareCredentials( byte[] receivedCredentials, byte[] storedCredentials )
    {
        LdapSecurityConstants algorithm = findAlgorithm( storedCredentials );

        if ( algorithm != null )
        {
            EncryptionMethod encryptionMethod = new EncryptionMethod( algorithm, null );

            // Let's get the encrypted part of the stored password
            // We should just keep the password, excluding the algorithm
            // and the salt, if any.
            // But we should also get the algorithm and salt to
            // be able to encrypt the submitted user password in the next step
            byte[] encryptedStored = PasswordUtil.splitCredentials( storedCredentials, encryptionMethod );

            // Reuse the saltedPassword informations to construct the encrypted
            // password given by the user.
            byte[] userPassword = PasswordUtil.encryptPassword( receivedCredentials, encryptionMethod.getAlgorithm(),
                encryptionMethod.getSalt() );

            // Now, compare the two passwords.
            return Arrays.equals( userPassword, encryptedStored );
        }
        else
        {
            return Arrays.equals( storedCredentials, receivedCredentials );
        }
    }


    /**
     * encrypts the given credentials based on the algorithm name and optional salt
     *
     * @param credentials the credentials to be encrypted
     * @param algorithm the algorithm to be used for encrypting the credentials
     * @param salt value to be used as salt (optional)
     * @return the encrypted credentials
     */
    public static byte[] encryptPassword( byte[] credentials, LdapSecurityConstants algorithm, byte[] salt )
    {
        switch ( algorithm )
        {
            case HASH_METHOD_SHA:
            case HASH_METHOD_SSHA:
                return digest( LdapSecurityConstants.HASH_METHOD_SHA, credentials, salt );

            case HASH_METHOD_SHA256:
            case HASH_METHOD_SSHA256:
                return digest( LdapSecurityConstants.HASH_METHOD_SHA256, credentials, salt );

            case HASH_METHOD_SHA384:
            case HASH_METHOD_SSHA384:
                return digest( LdapSecurityConstants.HASH_METHOD_SHA384, credentials, salt );

            case HASH_METHOD_SHA512:
            case HASH_METHOD_SSHA512:
                return digest( LdapSecurityConstants.HASH_METHOD_SHA512, credentials, salt );

            case HASH_METHOD_MD5:
            case HASH_METHOD_SMD5:
                return digest( LdapSecurityConstants.HASH_METHOD_MD5, credentials, salt );

            case HASH_METHOD_CRYPT:
                String saltWithCrypted = UnixCrypt.crypt( Strings.utf8ToString( credentials ), Strings
                    .utf8ToString( salt ) );
                String crypted = saltWithCrypted.substring( 2 );

                return Strings.getBytesUtf8( crypted );

            default:
                return credentials;
        }
    }


    /**
     * Compute the hashed password given an algorithm, the credentials and 
     * an optional salt.
     *
     * @param algorithm the algorithm to use
     * @param password the credentials
     * @param salt the optional salt
     * @return the digested credentials
     */
    private static byte[] digest( LdapSecurityConstants algorithm, byte[] password, byte[] salt )
    {
        MessageDigest digest;

        try
        {
            digest = MessageDigest.getInstance( algorithm.getAlgorithm() );
        }
        catch ( NoSuchAlgorithmException e1 )
        {
            return null;
        }

        if ( salt != null )
        {
            digest.update( password );
            digest.update( salt );
            return digest.digest();
        }
        else
        {
            return digest.digest( password );
        }
    }


    /**
     * Decompose the stored password in an algorithm, an eventual salt
     * and the password itself.
     *
     * If the algorithm is SHA, SSHA, MD5 or SMD5, the part following the algorithm
     * is base64 encoded
     *
     * @param encryptionMethod The structure to feed
     * @return The password
     * @param credentials the credentials to split
     */
    public static byte[] splitCredentials( byte[] credentials, EncryptionMethod encryptionMethod )
    {
        int algoLength = encryptionMethod.getAlgorithm().getPrefix().length() + 2;

        switch ( encryptionMethod.getAlgorithm() )
        {
            case HASH_METHOD_MD5:
            case HASH_METHOD_SHA:
                try
                {
                    // We just have the password just after the algorithm, base64 encoded.
                    // Just decode the password and return it.
                    return Base64
                        .decode( new String( credentials, algoLength, credentials.length - algoLength, "UTF-8" )
                            .toCharArray() );
                }
                catch ( UnsupportedEncodingException uee )
                {
                    // do nothing
                    return credentials;
                }

            case HASH_METHOD_SMD5:
                try
                {
                    // The password is associated with a salt. Decompose it
                    // in two parts, after having decoded the password.
                    // The salt will be stored into the EncryptionMethod structure
                    // The salt is at the end of the credentials, and is 8 bytes long
                    byte[] passwordAndSalt = Base64.decode( new String( credentials, algoLength, credentials.length
                        - algoLength, "UTF-8" ).toCharArray() );

                    int saltLength = passwordAndSalt.length - MD5_LENGTH;
                    encryptionMethod.setSalt( new byte[saltLength] );
                    byte[] password = new byte[MD5_LENGTH];
                    split( passwordAndSalt, 0, password, encryptionMethod.getSalt() );

                    return password;
                }
                catch ( UnsupportedEncodingException uee )
                {
                    // do nothing
                    return credentials;
                }

            case HASH_METHOD_SSHA:
                return getCredentials( credentials, algoLength, SHA1_LENGTH, encryptionMethod );

            case HASH_METHOD_SHA256:
            case HASH_METHOD_SSHA256:
                return getCredentials( credentials, algoLength, SHA256_LENGTH, encryptionMethod );

            case HASH_METHOD_SHA384:
            case HASH_METHOD_SSHA384:
                return getCredentials( credentials, algoLength, SHA512_LENGTH, encryptionMethod );

            case HASH_METHOD_SHA512:
            case HASH_METHOD_SSHA512:
                return getCredentials( credentials, algoLength, SHA512_LENGTH, encryptionMethod );

            case HASH_METHOD_CRYPT:
                // The password is associated with a salt. Decompose it
                // in two parts, storing the salt into the EncryptionMethod structure.
                // The salt comes first, not like for SSHA and SMD5, and is 2 bytes long
                encryptionMethod.setSalt( new byte[2] );
                byte[] password = new byte[credentials.length - encryptionMethod.getSalt().length - algoLength];
                split( credentials, algoLength, encryptionMethod.getSalt(), password );

                return password;

            default:
                // unknown method
                return credentials;

        }
    }


    /**
     * Compute the credentials
     */
    private static byte[] getCredentials( byte[] credentials, int algoLength, int hashLen,
        EncryptionMethod encryptionMethod )
    {
        try
        {
            // The password is associated with a salt. Decompose it
            // in two parts, after having decoded the password.
            // The salt will be stored into the EncryptionMethod structure
            // The salt is at the end of the credentials, and is 8 bytes long
            byte[] passwordAndSalt = Base64.decode( new String( credentials, algoLength, credentials.length
                - algoLength, "UTF-8" ).toCharArray() );

            int saltLength = passwordAndSalt.length - hashLen;
            encryptionMethod.setSalt( new byte[saltLength] );
            byte[] password = new byte[hashLen];
            split( passwordAndSalt, 0, password, encryptionMethod.getSalt() );

            return password;
        }
        catch ( UnsupportedEncodingException uee )
        {
            // do nothing
            return credentials;
        }
    }


    private static void split( byte[] all, int offset, byte[] left, byte[] right )
    {
        System.arraycopy( all, offset, left, 0, left.length );
        System.arraycopy( all, offset + left.length, right, 0, right.length );
    }


    private static void merge( byte[] all, byte[] left, byte[] right )
    {
        System.arraycopy( left, 0, all, 0, left.length );
        System.arraycopy( right, 0, all, left.length, right.length );
    }


    /**
     * checks if the given password's change time is older than the max age 
     *
     * @param pwdChangedZtime time when the password was last changed
     * @param pwdMaxAgeSec the max age value in seconds
     * @return true if expired, false otherwise
     */
    public static boolean isPwdExpired( String pwdChangedZtime, int pwdMaxAgeSec )
    {
        Date pwdChangeDate = DateUtils.getDate( pwdChangedZtime );

        long time = pwdMaxAgeSec * 1000L;//DIRSERVER-1735
        time += pwdChangeDate.getTime();

        Date expiryDate = DateUtils.getDate( DateUtils.getGeneralizedTime( time ) );
        Date now = DateUtils.getDate( DateUtils.getGeneralizedTime() );

        boolean expired = false;

        if ( expiryDate.equals( now ) || expiryDate.before( now ) )
        {
            expired = true;
        }

        return expired;
    }
}
