<?php
/*******************************************************************************
 * Copyright (C) 2007 Easter-eggs
 * https://ldapsaisie.org
 *
 * Author: See AUTHORS file in top-level directory.
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License version 2
 * as published by the Free Software Foundation.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

******************************************************************************/

/**
 * Ldap attribute type password
 *
 */
class LSattr_ldap_password extends LSattr_ldap {

  /**
   * Cache of the new password in raw format (=clear, not hashed)
   * @see LSattr_ldap_password::getUpdateData()
   * @see LSattr_ldap_password::getClearPassword()
   * @var string
   */
  private $clearPassword = '';

  /**
   * Return the display value of this attribute
   *
   * @param mixed $data The value of this attribute
   *
   * @return mixed The display value of this attribute
   */
  public function getDisplayValue($data) {
    if ($this -> getConfig('ldap_options.displayClearValue', false, 'bool')) {
      $ret=array();
      $wildcardPassword = $this -> getConfig('ldap_options.wildcardPassword');
      $encodedWildcardPassword = $this -> getConfig('ldap_options.encodedWildcardPassword');
      foreach(ensureIsArray($data) as $p) {
        if ($p == $wildcardPassword || $p == $encodedWildcardPassword)
          continue;
        $ret[] = $p;
      }
      return $ret;
    }
    return array('********');
  }

  /**
   * Return the value of this attribute to be stocked
   *
   * Note : Password encoding was strongly inspired of the project phpLdapAdmin.
   * URL : http://phpldapadmin.sourceforge.net/
   *
   * @param mixed $data The attribute value
   *
   * @return mixed The value of this attribute to be stocked
   */
  public function getUpdateData($data) {
    $ret = array();
    foreach(ensureIsArray($data) as $val) {
      $this -> clearPassword = $val;
      $ret[] = $this -> encodePassword($val);
    }

    // Wildcard Password
    foreach(ensureIsArray($this -> getConfig('ldap_options.wildcardPassword')) as $pwd)
      $ret[] = $this -> encodePassword($pwd);

    // Wildcard Password already encoded
    foreach(ensureIsArray($this -> getConfig('ldap_options.encodedWildcardPassword')) as $pwd)
      $ret[] = $pwd;

    return $ret;
  }

  /**
   * Encode the password
   *
   * Note : Password encoding was strongly inspired of the project phpLdapAdmin.
   * URL : http://phpldapadmin.sourceforge.net/
   *
   * @param string $clearPassword The clear password
   * @param string|null $encode The encoding type
   * @param callable $encode_function The encoding callable
   *
   * @return string The encode password
   */
  public function encodePassword($clearPassword, $encode=null, $encode_function=null, $salt=null) {
    if (is_null($encode))
      $encode = $this -> getConfig('ldap_options.encode', 'md5crypt', 'string');
    if (is_null($encode_function))
      $encode_function = $this -> getConfig('ldap_options.encode_function');
    if ($encode_function || $encode == 'function') {
      if ( (!$encode_function) || (!is_callable($encode_function)) ) {
        $encode = 'clear';
        $encode_function = null;
        LSerror :: addErrorCode('LSattr_ldap_password_02', ($encode_function?$encode_function:__('undefined')));
      }
      else {
        $encode = 'function';
      }
    }
    switch($encode) {
      case 'crypt':
        if ($this -> getConfig('ldap_options.no_random_crypt_salt')) {
          return '{CRYPT}' . crypt($clearPassword,substr($clearPassword,0,2));
        }
        else {
          if (is_null($salt))
            $salt = $this -> getSalt(2);
          return '{CRYPT}' . crypt($clearPassword, $salt);
        }
        break;
      case 'ext_des':
        if ( ! defined( 'CRYPT_EXT_DES' ) || CRYPT_EXT_DES == 0 ) {
          LSerror :: addErrorCode('LSattr_ldap_password_01','ext_des');
        }
        else {
          if (is_null($salt))
            $salt = $this -> getSalt(8);
          return '{CRYPT}' . crypt( $clearPassword, '_' . $salt );
        }
        break;
      case 'blowfish':
        if( ! defined( 'CRYPT_BLOWFISH' ) || CRYPT_BLOWFISH == 0 ) {
          LSerror :: addErrorCode('LSattr_ldap_password_01','blowfish');
        }
        else {
          if (is_null($salt))
            $salt = '$2y$12$' . $this -> getSalt(22);
          return '{CRYPT}' . crypt( $clearPassword, $salt );
        }
        break;
      case 'sha':
        if( function_exists('sha1') ) {
          return '{SHA}' . base64_encode( pack( 'H*' , sha1( $clearPassword ) ) );
        }
        elseif( function_exists( 'mhash' ) ) {
          return '{SHA}' . base64_encode( mhash( MHASH_SHA1, $clearPassword ) );
        } else {
          LSerror :: addErrorCode('LSattr_ldap_password_01','sha');
        }
        break;
      case 'sha256':
      case 'sha512':
        $mhash_type = null;
        switch($encode) {
          case 'sha256':
            $mhash_type = MHASH_SHA256;
            break;
          case 'sha512':
            $mhash_type = MHASH_SHA512;
            break;
        }
        if( function_exists( 'mhash' ) ) {
          return '{'.strtoupper($encode).'}' . base64_encode( mhash( $mhash_type, $clearPassword ) );
        } else {
          LSerror :: addErrorCode('LSattr_ldap_password_01', $encode);
        }
        break;
      case 'ssha':
      case 'ssha256':
      case 'ssha512':
        $mhash_type = null;
        switch($encode) {
          case 'ssha':
            $mhash_type = MHASH_SHA1;
            break;
          case 'ssha256':
            $mhash_type = MHASH_SHA256;
            break;
          case 'ssha512':
            $mhash_type = MHASH_SHA512;
            break;
        }
        if( function_exists( 'mhash' ) && function_exists( 'mhash_keygen_s2k' ) ) {
          mt_srand( (double) microtime() * 1000000 );
          if (is_null($salt))
            $salt = mhash_keygen_s2k( $mhash_type, $clearPassword, substr( pack( "h*", md5( mt_rand() ) ), 0, 8 ), 4 );
          return "{".strtoupper($encode)."}".base64_encode( mhash( $mhash_type, $clearPassword.$salt ).$salt );
        }
        else {
          LSerror :: addErrorCode('LSattr_ldap_password_01', $encode);
        }
        break;
      case 'smd5':
        if( function_exists( 'mhash' ) && function_exists( 'mhash_keygen_s2k' ) ) {
          mt_srand( (double) microtime() * 1000000 );
          if (is_null($salt))
            $salt = mhash_keygen_s2k( MHASH_MD5, $clearPassword, substr( pack( "h*", md5( mt_rand() ) ), 0, 8 ), 4 );
          return "{SMD5}".base64_encode( mhash( MHASH_MD5, $clearPassword.$salt ).$salt );
        }
        else {
          LSerror :: addErrorCode('LSattr_ldap_password_01','smd5');
        }
        break;
      case 'md5':
        return '{MD5}' . base64_encode( pack( 'H*' , md5( $clearPassword ) ) );
        break;
      case 'md5crypt':
        if( ! defined( 'CRYPT_MD5' ) || CRYPT_MD5 == 0 ) {
          LSerror :: addErrorCode('LSattr_ldap_password_01','md5crypt');
        }
        else {
          if (is_null($salt))
            $salt = $this -> getSalt();
          return '{CRYPT}'.crypt($clearPassword,'$1$'.$salt.'$');
        }
        break;
      case 'argon2':
      case 'argon2i':
        if( ! defined( 'PASSWORD_ARGON2I' ) ) {
          LSerror :: addErrorCode('LSattr_ldap_password_01', 'argon2');
        }
        else {
          return '{ARGON2}'.password_hash($clearPassword, PASSWORD_ARGON2I);
        }
        break;
      case 'argon2id':
        if( ! defined( 'PASSWORD_ARGON2ID' ) ) {
          LSerror :: addErrorCode('LSattr_ldap_password_01', 'argon2id');
        }
        else {
          return '{ARGON2}'.password_hash($clearPassword, PASSWORD_ARGON2ID);
        }
        break;
      case 'clear':
        return $clearPassword;
        break;
      case 'function':
        return call_user_func_array($encode_function, array(&$this -> attribute -> ldapObject, $clearPassword));
        break;
    }
    LSerror :: addErrorCode('LSattr_ldap_password_01', $encode);
    return $clearPassword;
  }

  /**
   * Verify if the clear specified password match with specified hashed password(s)
   * @param  string $clearPassword  The clear password to check
   * @param  string|array|null $hashedPassword The hashed password(s) (optional, default: check againt attibute values)
   * @return bool True if clear password matched, False otherwise
   */
  function verify($clearPassword, $hashedPassword=null) {
    // If $hashedPassword is not provided, use attribute values
    if (is_null($hashedPassword))
      $hashedPassword = $this -> attribute -> getValue();

    // If $hashedPassword is array, iter to find valid password
    if (is_array($hashedPassword)) {
      foreach($hashedPassword as $pwd)
        if ($this -> verify($clearPassword, $pwd))
          return true;
      return false;
    }
    // Verify $hashedPassword is a string
    elseif (!is_string($hashedPassword))
      return false;

    // Custom verify function configured ? If yes, use it
    $verifyFunction = $this -> getConfig('ldap_options.verify_function', null);
    if (!is_null($verifyFunction) && is_callable($verifyFunction))
      return call_user_func_array($verifyFunction, array(&$this -> attribute -> ldapObject, $clearPassword, $hashedPassword));

    // Custom encode function configured ? If yes, use it
    $encodeFunction = $this -> getConfig('ldap_options.encode_function', null);
    if (!is_null($encodeFunction) && is_callable($encodeFunction))
      return (strcasecmp(call_user_func_array($encodeFunction, array(&$this -> attribute -> ldapObject, $clearPassword)), $hashedPassword) == 0);

    // Extract cipher
    $hashedPasswordData = $cypher = null;
    if (preg_match('/{([^}]+)}(.*)/',$hashedPassword,$matches)) {
      $hashedPasswordData = $matches[2];
      $cypher = strtolower($matches[1]);
    }

    // Verify password according on cypher
    switch($cypher) {
      # SSHA crypted passwords
      case 'ssha':
      case 'ssha256':
      case 'ssha512':
      case 'smd5':
        $data = base64_decode($hashedPasswordData);
        # Salt = last 4 bytes for SSHA / SMD5 and last 8 bytes for SSH256 / SSHA512
        if ($cypher == 'ssha' || $cypher == 'smd5')
          $salt_size = 4;
        else
          $salt_size = 8;
        $salt = substr($data, -$salt_size);
        $new_hash = $this -> encodePassword($clearPassword, $cypher, null, $salt);
        return (strcmp($hashedPassword,$new_hash) == 0);
        break;

      # Non-salted cyphers
      case 'sha':
      case 'sha256':
      case 'sha512':
      case 'md5':
        $new_hash = $this -> encodePassword($clearPassword, $cypher);
        return (strcasecmp($new_hash, $hashedPassword) == 0);
        break;

      # Crypt passwords
      case 'crypt':
        # Check if it's blowfish crypt
        if (preg_match('/^\\$2+/',$hashedPasswordData)) {
          list($dummy, $version, $rounds, $salt_hash) = explode('$',$hashedPasswordData);
          $salt = '$'.$version.'$'.$rounds.'$'.substr($salt_hash, 0, 22);
          $new_hash = $this -> encodePassword($clearPassword, 'blowfish', null, $salt);
          return (strcasecmp($new_hash, $hashedPassword) == 0);
        }

        # Check if it's an md5crypt
        elseif (strstr($hashedPasswordData,'$1$')) {
          list($dummy,$type,$salt,$hash) = explode('$',$hashedPasswordData);
          $new_hash = $this -> encodePassword($clearPassword, 'md5crypt', null, $salt);
          return (strcasecmp($new_hash, $hashedPassword) == 0);
        }

        # Check if it's ext_des crypt
        elseif (strstr($hashedPasswordData,'_')) {
          return (crypt($clearPassword,$hashedPasswordData) == $hashedPasswordData);
        }

        # Password is plain crypt
        else {
          return (crypt($clearPassword,$hashedPasswordData) == $hashedPasswordData);
        }

        break;

      # Argon2 passwords
      case 'argon2':
        return password_verify($clearPassword, $hashedPasswordData);

      # No crypt is given
      default:
        # Assume is a plaintext password
        return (strcasecmp($clearPassword, $hashedPassword) == 0);
    }
    // It's supposed to never append, but just in case, return false
    return false;
  }

  /**
   * Return salt (random string)
   *
   * @param integer $length Number of caracters in this salt
   *
   * @return string A salt
   */
  public static function getSalt($length=8) {
    $pattern = "1234567890abcdefghijklmnopqrstuvwxyz";
    $key  = $pattern[rand(0,35)];
    for($i=1;$i<$length;$i++)
    {
        $key .= $pattern[rand(0,35)];
    }
    return $key;
  }

  /**
   * Return the password in clear text
   *
   * @return string The password in clear text
   */
  public function getClearPassword() {
    return $this -> clearPassword;
  }

}

/**
 * Error Codes
 **/
LSerror :: defineError('LSattr_ldap_password_01',
___("LSattr_ldap_password : Encoding type %{type} is not supported. This password will be stored in clear text.")
);
LSerror :: defineError('LSattr_ldap_password_02',
___("LSattr_ldap_password : Encoding function %{function} is not callable. This password will be stored in clear text.")
);
